[Flutter]Mockito를 이용한 단위 테스트 작성

한상욱·2024년 8월 28일
0

Flutter

목록 보기
19/26
post-thumbnail

들어가며

Flutter에서 네트워크 통신(예를 들어, Http 통신) 등을 이용하는 경우 Data source인 Repository를 정의합니다. 보통 이러한 경우 바로 코드를 작성하고 연결하는 것 보다는 테스트를 작성하면 효율적으로 비즈니스 로직의 정상적인 동작 여부를 확인할 수 있습니다.

하지만, 이렇게 되면 각 Layer마다 필요한 의존성이 있으므로 해당 Layer 뿐만 아니라 depency injection 하는 모든 Layer의 동작이 필요합니다. 이러한 경우 mock을 사용할 수 있습니다.

Test의 방향에 따라 가상의 모듈의 이름이 Test Driver, Test Stub으로 나뉘는데. 이 경우에는 하위 모듈인 Test Stub이라고 합니다.

테스트 코드를 작성하기 위해서 간단하게 네트워크 통신을 하는 앱을 제작해보겠습니다. 상태관리 라이브러리는 provider, 아키텍쳐는 mvvm 패턴을 이용할 것입니다. 또한, 네트워크 통신은 Dio를 이용하겠습니다.

Todo Model

part 'todo.freezed.dart';
part 'todo.g.dart';


class Todo with _$Todo {
  const factory Todo({
    required int? id,
    required String? title,
  }) = _Todo;

  factory Todo.fromJson(Map<String, dynamic> json) => _$TodoFromJson(json);
}

Todo 모델을 정의하였습니다. freezed를 이용해서 간단하게 작성하였습니다. Todo는 간단하게 id, title을 갖도록 했습니다.

Todo Repository

import 'package:dio/dio.dart';
import 'package:todo_list/src/model/todo.dart';

class TodoRepository {
  final Dio dio;
  TodoRepository({required this.dio});
	
  
  /// 전체 조회 API
  Future<List<Todo>> getAllTodos() async {
    return dio.get("/todo").then((response) =>
        List<Map<String, dynamic>>.from(response.data)
            .map((json) => Todo.fromJson(json))
            .toList());
  }

  /// 생성 API
  Future<Todo> createTodo(Todo todo) async {
    return dio
        .post("/todo", data: todo.toJson())
        .then((response) => Todo.fromJson(response.data));
  }
  
  
  /// 삭제 API
  Future<String> deleteTodo(int id) async {
    return dio.delete("/todo/$id").then((response) => response.data);
  }
}

Todo Repository는 실질적인 엔드포인트의 역할입니다. 저는 자체적으로 서버를 구축했습니다. 따라서, 위에 세가지 메소드밖에 존재하지 않습니다. 조회, 생성, 삭제입니다.

여기서, 중요한 점은 Dio를 외부에서 주입받아 테스트 가능 구조를 정의한다는 것입니다. 이는 테스트 코드를 작성할 때, mock을 생성하기 쉽게 해줍니다.

App View Model

class AppViewModel extends ChangeNotifier {
  List<Todo> _todos = List.empty(growable: true);

  List<Todo> get todos => _todos;

  final TodoRepository todoRepository;

  final TextEditingController _titleController = TextEditingController();

  TextEditingController get titleController => _titleController;

  String title = "";

  onChange(String value) {
    title = value;
  }

  AppViewModel({required this.todoRepository}) {
    fetchData();
  }
	
    
  /// 전체 조회
  void fetchData() async {
    try {
      final result = await todoRepository.getAllTodos();
      _todos.clear();
      _todos.addAll(result);
      notifyListeners();
    } catch (err) {
      print(err.toString());
    }
  }
	
    
  // 생성
  void createTodo() async {
    if (title.isEmpty) {
      return;
    }
    final newTodo = Todo(id: null, title: title);
    try {
      final result = await todoRepository.createTodo(newTodo);
      print(result);
      _todos.add(result);
      notifyListeners();
    } catch (err) {
      print(err.toString());
    }
  }
	
  /// 삭제
  void deleteTodo(int id) async {
    try {
      await todoRepository.deleteTodo(id);
      _todos = _todos.where((e) => e.id != id).toList();
      notifyListeners();
    } catch (err) {
      print(err.toString());
    }
  }
}

자, 이제 UI를 제외하고 필요한 비즈니스 로직 작성이 완성되었습니다. 이제 이 모듈들이 정상적으로 동작하는지 확인하기 위하여 테스트 코드를 작성하겠습니다.

Mockito

Mockito는 mock을 간단하게 생성하게 도와주는 라이브러리입니다. 이를 위해서 테스트 코드 작성을 위해서 필요한 하위 모듈을 mock으로 생성할 것입니다. 예를 들어, repository는 Dio가 필요하며, viewModel은 repository가 필요합니다.

Repository Unit Test

Repository의 테스트 코드 작성을 위해서 Dio의 mock을 생성해 줄것입니다.

import 'todo_repository_test.mocks.dart';

([Dio])
void main() {
  late Dio dio;

  late TodoRepository todoRepository;
  
  ...
}

Mockito는 @GenereateMocks 어노테이션을 통해 Test Stub의 코드 제네레이션을 수행합니다. 어노테이션에는 배열을 전달해줄 수 있으며, 배열에는 생성하기 원하는 모듈을 전달하면 됩니다.

dart run build_runner build

위 명령어를 통해 코드 제네레이션을 수행할 수 있습니다.

❗️ 코드 제네레이션을 위해서는 build_runner가 필요합니다. 사전에 터미널에서 아래의 명령어를 수행해서 build_runner를 추가해주세요.

$ flutter pub add --dev build_runner

이제, mock을 생성했으니, 전체 조회에 대한 첫번째 테스트를 수행해보겠습니다.

import 'todo_repository_test.mocks.dart';

([Dio])
void main() {
    late Dio dio;

    late TodoRepository todoRepository;


    // 테스트 전 의존성 주입
    setUp(() {
      dio = MockDio();
      todoRepository = TodoRepository(dio: dio);
    });
    
    test("Todo 조회 테스트", () async {
      final data = [
        {"id": 1, "title": "todo 1"},
        {"id": 2, "title": "todo 1"},
        {"id": 3, "title": "todo 1"},
      ];
		
      // when
      when(dio.get("/todo")).thenAnswer((_) async => Response(
          requestOptions: RequestOptions(path: "http://localhost:8080/api"),
          data: data,
          statusCode: 200));
          
	  // given
      final result = await todoRepository.getAllTodos();
	  
      
      // then
      verify(dio.get("/todo")).called(1);

      expect(result.length, 3);
    });

테스트는 When - Given - Then 패턴으로 작성되었습니다.

1. when

mock들은 테스트를 위해서 가상의 로직을 등록해야 합니다. 이를 when을 통해서 지정할 수 있습니다. Dio의 가상의 반환을 지정했기에 Response를 반환하도록 하였습니다.

2. given

테스트 코드에서 실제로 수행하는 테스트의 실행입니다.

3. then

테스트의 실행 검증을 확인합니다. verify는 stub에서 해당 로직이 몇번 수행되었는지 검증할 수 있습니다. expect는 예측값을 검증할 수 있습니다.

이를 통해서 나머지 경우에 대한 테스트 코드를 작성하겠습니다. 테스트 코드가 여러개인 경우 group 테스트로 수행할 수 있습니다.

([Dio])
void main() {
  late Dio dio;

  late TodoRepository todoRepository;

  setUp(() {
    dio = MockDio();
    todoRepository = TodoRepository(dio: dio);
  });

  group("Todo Repository Unit Test", () {
    test("Todo 조회 테스트", () async {
      final data = [
        {"id": 1, "title": "todo 1"},
        {"id": 2, "title": "todo 1"},
        {"id": 3, "title": "todo 1"},
      ];

      when(dio.get("/todo")).thenAnswer((_) async => Response(
          requestOptions: RequestOptions(path: "http://localhost:8080/api"),
          data: data,
          statusCode: 200));

      final result = await todoRepository.getAllTodos();

      verify(dio.get("/todo")).called(1);

      expect(result.length, 3);
    });

    test("Todo 생성 테스트", () async {
      const testTodo = Todo(id: null, title: "todo1");
      when(dio.post("/todo", data: testTodo.toJson())).thenAnswer((_) async =>
          Response(
              requestOptions:
                  RequestOptions(baseUrl: "http://localhost:8080/api"),
              statusCode: 201,
              data: {"id": 1, "title": "todo1"}));

      final result = await todoRepository.createTodo(testTodo);

      verify(dio.post("/todo", data: testTodo.toJson())).called(1);

      expect(result.id, 1);
      expect(result.title, "todo1");
    });

    test("Todo 삭제 테스트", () async {
      when(dio.delete("/todo/1")).thenAnswer((_) async => Response(
          requestOptions: RequestOptions(baseUrl: "http://localhost:8080/api"),
          statusCode: 200,
          data: "삭제"));

      final result = await todoRepository.deleteTodo(1);

      verify(dio.delete("/todo/1")).called(1);

      expect(result, "삭제");
    });
  });
}

자, 어떤가요? Mockito를 이용하여 간단하게 repository의 단위테스트를 수행했습니다.

View Model Test

View Model Test도 Repository 테스트와 비슷하게 작성할 수 있습니다.

import 'app_view_model_test.mocks.dart';

([TodoRepository])
void main() {
  late TodoRepository todoRepository;
  late AppViewModel viewModel;

여기서는 TodoRepository의 mock을 생성하였습니다. 이제, 각각의 로직에 대한 단위 테스트 코드를 한번 보도록 하겠습니다.

import 'app_view_model_test.mocks.dart';

([TodoRepository])
void main() {
  late TodoRepository todoRepository;
  late AppViewModel viewModel;

  final List<Todo> tTodos = [const Todo(id: 1, title: "test1")];

  group("App View Model Unit Test", () {
    setUp(() async {
      todoRepository = MockTodoRepository();
      when(todoRepository.getAllTodos()).thenAnswer((_) async => tTodos);
      viewModel = AppViewModel(todoRepository: todoRepository);
      await Future.delayed(const Duration(seconds: 1));
      expect(viewModel.todos.length, 1);
    });

    test("fetchData 테스트", () async {
      when(todoRepository.getAllTodos()).thenAnswer((_) async => tTodos);

      expect(viewModel.todos.length, 1);

      viewModel.fetchData();

      await Future.delayed(const Duration(seconds: 1));

      expect(viewModel.todos.length, 1);
    });

    test("createTodo 테스트", () async {
      const newTodo = Todo(id: null, title: "new todo");
      const response = Todo(id: 2, title: "new todo");
      when(todoRepository.createTodo(newTodo))
          .thenAnswer((_) async => response);
      expect(viewModel.todos.length, 1);

      viewModel.title = "new todo";

      viewModel.createTodo();

      await Future.delayed(const Duration(seconds: 1));

      verify(todoRepository.createTodo(newTodo)).called(1);
      expect(viewModel.todos.length, 2);
    });

    test("deleteTodo 테스트", () async {
      when(todoRepository.deleteTodo(1)).thenAnswer((_) async => "성공");

      expect(viewModel.todos.length, 1);

      viewModel.deleteTodo(1);

      await Future.delayed(const Duration(seconds: 1));

      expect(viewModel.todos.length, 0);
    });
  });
}

이번에는 아까와는 다르게 Future.delay 함수로 조금의 딜레이 후 검증을 진행했는데요. 그 이유는 viewModel의 메소드는 단순 void 함수이므로 로직의 수행을 완전히 기다려야 되는 필요가 있기 때문입니다. 그래야 todos의 정확한 값이 등록될테니 말이죠.

이로써, Mockito를 이용하여 단위 테스트를 작성하는 방법과 테스트 가능 구조로 코드를 작성하는 방법에 대해서 알아보았습니다.

profile
자기주도적, 지속 성장하는 모바일앱 개발자가 되기 위해

0개의 댓글