[Flutter]TDD 1 - Unit Test

한상욱·2023년 11월 13일
0

Flutter

목록 보기
4/35
post-thumbnail

들어가며

Flutter를 통해 처음 개발을 시작하면 시뮬레이터를 통해 작성한 코드가 올바르게 동작하는지 확인하며 개발을 시작합니다. "잘 돌아가겠지?"라는 부푼 마음을 갖고 실행을 하죠. 하지만, 예기치 못한 에러나 예외 상황을 통해 수많은 에러를 마주하게 됩니다.

매번 직접 시뮬레이터를 직접 눈으로 확인하는 일은 간단한 앱에서는 가능해도, 앱의 기능이 복잡해지고 UI가 증가할수록 점점 귀찮아질 수 있습니다. 그래서 개발자는 테스트 코드를 통해 내가 작성한 코드의 정확성을 검증합니다.

Flutter에서는 Test 라이브러리를 이용하여 간편하게 Test 코드를 작성하고 실행해볼 수 있습니다. 그렇기에 이번에는 대략적으로 단위테스트(Unit Test), TDD, Mocking, 테스트 가능 구조 설계에 대해서 알아보도록 하겠습니다.

TDD

소프트웨어 개발에서 테스트는 코드의 정확성을 검증하고 결함을 조기에 발견하는데 핵심적인 역할을 수행하게 됩니다. 이로 인해 TDD가 등장하였습니다.

테스트 주도 개발(Test Driven Development)의 약자를 딴 TDD는 소프트웨어 개발 방법론 중 하나입니다. 말 그대로 테스트 주도 개발은 테스트를 먼저 작성하고 개발을 진행합니다.

일반적으로 먼저 코드를 구현합니다. 이후 검증을 수행합니다. 하지만, TDD는 거꾸로 수행하게 됩니다.

  1. 실패하는 테스트 케이스를 작성한다.
  2. 테스트를 통과하기 위한 최소한의 코드를 구현한다.
  3. 보일러 플레이트 코드를 줄이고 깔끔하게 다듬는다.

이 과정을 레드 그린 리팩터링이라고 합니다.
TDD의 핵심은 기능의 완성이 아닌 테스트의 통과입니다. 이를 통해 자연스럽게 테스트 가능한 구조로 코드를 구현하게 되고 변경에도 문제가 없는 코드가 완성됩니다.

Unit Test

단위테스트(Unit Test)는 프로그램의 가장 기본적인 단위인 모듈을 테스트 하는것을 의미합니다. 그런 의미에서 모듈테스트라고도 할 수 있습니다. TDD에서 가장 중심적인 것이 바로 단위테스트이죠.

Flutter 프로젝트에서 단위 모듈이란 무엇일까요? OOP를 이용하여 프로그램을 작성하고 있다면, controller, service, repository 등등이 있을것입니다. 하지만 여기서는 가장 기본적인 Counter를 만들어서 확인해보겠습니다.

저희가 만들 Counter는 초기에 0이라는 값을 가지고 있습니다. 그리고 increase, decrease함수를 통해서 그 값에 1을 더하거나 빼는 기능이 있다고 하겠습니다. 이 정보를 바탕으로 아래의 class를 만들어볼 수 있습니다.

class Counter {
  int value = 0;

  void increase() => value++;

  void decrease() => value--;
}

이제 Counter를 위한 단위테스트 코드를 작성해보죠. 테스트는 다음과 같은 것들을 확인해야하겠죠?

  • Counter의 초기 값은 0이다.
  • Counter의 increase함수는 값에 1을 더한다.
  • Counter의 decrease함수는 값에 1을 뺀다.

따라서, 3가지의 테스트 케이스를 작성할 수 있을것입니다.

void main() {
  group(Counter, () {
    test("should initial value returns 0", () {
      final counter = Counter();
      expect(counter.value, 0);
    });

    test("should increase value by 1 when increase() is called", () {
      final counter = Counter();
      counter.increase();

      expect(counter.value, 1);
    });

    test("should decrease value by 1 when decrease() is called", () {
      final counter = Counter();
      counter.decrease();

      expect(counter.value, -1);
    });
  });
}

test메소드를 통해 decription과 테스트 수행 로직을 작성할 수 있습니다. group메소드를 이용한다면 여러 테스트를 함께 묶을 수 있습니다.

IDE의 디버깅 혹은 flutter test 명령어를 통해서 테스트를 수행할 수 있습니다.

(참고로, flutter test [test file path]를 통해 원하는 테스트만 실행할 수도 있습니다.)

이것이 일반적인 개발 과정입니다. 하지만 TDD를 적용해서 다시 해당 작업을 수행해봅시다. Counter 클래스의 value는 0으로 시작하지만, 메소드는 구현하지 않고 정의만 합니다.

class Counter {
  int value = 0;

  void increase() {}

  void decrease() {}
}

이 상태로 우리는 테스트를 작성합니다. increase함수를 예시로 테스트를 작성해봅시다.

void main() {
  group(Counter, () {
    test("should initial value returns 0", () {
      final counter = Counter();
      expect(counter.value, 0);
    });

    test("should increase value by 1 when increase() is called", () {
      final counter = Counter();
      counter.increase();

      expect(counter.value, 1);
    });
  });
}

초기값은 항상 0이지만 우리는 increase()함수를 정의하지 않았습니다. 때문에 우리가 작성한 테스트 케이스는 실패해야 합니다.

우리의 예상대로 해당 테스트는 실패하였습니다. 이 과정은 매우 중요합니다.

만약 테스트가 통과하였다면, 메소드를 정의하지 않았다는 것을 이미 우리는 알고 있기에 해당 테스트를 잘못 작성했다는 것을 알 수 있습니다. 테스트를 테스트하며 우리는 테스트 결과에 대한 확신을 가질 수 있습니다.

이제 테스트를 통과하기 위한 최소한의 로직을 작성합니다.

class Counter {
  int value = 0;

  void increase() {
    value += 1;
  }

  void decrease() {}
}

이 로직을 통해 value는 1증가하게 됩니다.

우리는 이를 통해 최소한의 로직으로 테스트를 통과시켰습니다.!

메소드의 로직을 더욱 깔끔하고 간결하게 바꿀 수 있습니다. 바로 리팩토링입니다.

class Counter {
  int value = 0;

  void increase() => value++;

  void decrease() {}
}

코드가 여러 줄이 아니라면 람다 표현식으로 간결하게 표현이 가능하며 1이 증가되는 것은 증감 연산자를 통해 더 간결하게 표현이 가능합니다.

리팩토링한 코드 역시도 테스트를 통과합니다. 리팩토링을 수행한 코드가 로직을 변화시키지 않았다는 것을 증명합니다.

decrease()도 동일한 과정을 거칠 수 있습니다.

void main() {
  group(Counter, () {
    test("should initial value returns 0", () {
      final counter = Counter();
      expect(counter.value, 0);
    });

    test("should increase value by 1 when increase() is called", () {
      final counter = Counter();
      counter.increase();

      expect(counter.value, 1);
    });
	// decrease()메소드 테스트 추가
    test("should decrease value by 1 when decrease() is called", () {
      final counter = Counter();
      counter.decrease();

      expect(counter.value, -1);
    });
  });
}

마찬가지로 테스트를 통과할 수 있는 최소한의 로직을 작성합니다. 작성할 때부터 최소한으로 깔끔하게 작성할 수 있다면 그렇게 하는 것이 좋습니다.

class Counter {
  int value = 0;

  void increase() => value++;

  void decrease() => value--;
}

이를 통해 TDD 개발 방법론의 기본적인 프로세스를 알아보며 단위 테스트에 대해서 알아보았습니다.

Mocking

하지만, 단위 테스트를 수행하는 경우 단위 모듈이 다른 모듈을 참조하는 경우가 발생할 수 있습니다.

예를 들어, 아래 코드를 보겠습니다.

import 'dart:convert';
import 'package:hello_world/src/models/todo.dart';
import 'package:http/http.dart' as http;

class TodoApi {
  /// Fetch the todo list from the API
  Future<List<Todo>> fetchTodoList() async {
    final todosJson = await http.get(Uri.parse('your-base-url/todos'));

    final todos =
        List<Map<String, dynamic>>.from(jsonDecode(todosJson.body) as List)
            .map((json) => Todo.fromJson(Map<String, dynamic>.from(json)))
            .toList();
    return todos;
  }
}

이 엔드포인트는 RESTApi를 통해 서버로부터 Todo 목록을 가져오게 됩니다.

보통 엔드포인트는 Repository에서 로컬과 리모트 엔드포인트의 로직으로 작성됩니다.
다만, 로컬은 제외하고 리모트를 통해서 데이터를 불러온다고 하겠습니다.

class TodoRepository {
  Future<List<Todo>> getTodos() {
    throw UnimplementedError();
  }
}

위 레포지토리를 테스트 하기 위해서 테스트를 작성해야 합니다.

void main() {
  late TodoRepository repository;

  setUp(() {
    repository = TodoRepository();
  });

  test('should return a list of todos from the repository', () async {
    final todos = await repository.getTodos();
    
    expect(todos, isA<List<Todo>>());
    expect(todos.isNotEmpty, true); // 최소 1개 이상 있어야 함
  });
}

이 테스트는 마찬가지로 구현되어 있지 않기에 실패하게 됩니다.

이제 최소한의 구현으로 테스트를 통과시키면 됩니다.

다만, 문제가 생겼습니다. 레포지토리에서는 엔드포인트의 메소드를 통해서 Todo목록을 불러와야 하는데, 아래와 같이 작성할 경우 단위의 의존성이 생겨버립니다.

class TodoRepository {
  final TodoApi api = TodoApi(); // 모듈의 의존성이 추가됨.

  Future<List<Todo>> getTodos() {
    throw UnimplementedError();
  }
}

이러한 경우 테스트 코드에서도 TodoApi에 대한 의존성이 추가됩니다. 우리는 이러한 의존성이 없어야 합니다. 왜냐하면 TodoApi가 TodoRepository의 테스트 결과에 영향을 주기 때문입니다.

또한, TodoApi의 메소드는 반드시 성공한다는 보장이 없습니다. 서버와의 연결 상태에 따라서 혹은 DB의 데이터에 따라서 각각 다른 데이터가 오거나 에러를 던지게 됩니다.

class TodoApi {
  /// Fetch the todo list from the API
  /// 과연 반드시 성공할 수 있을까?
  Future<List<Todo>> fetchTodoList() async {
    final todosJson = await http.get(Uri.parse('your-base-url/todos'));

    final todos =
        List<Map<String, dynamic>>.from(jsonDecode(todosJson.body) as List)
            .map((json) => Todo.fromJson(Map<String, dynamic>.from(json)))
            .toList();
    return todos;
  }
}

이럴 때, 우리는 Test Stub을 통해서 하위 모듈의 역할을 수행할 수 있는 가상의 모듈이 필요해집니다. 이 모듈은 우리가 원하는 대로 동작을 제어할 수 있어야 하며, 이 모듈에 대한 의존성을 없애야 원할하게 테스트 작성이 가능합니다.

이 과정을 Mocking이라고 합니다.

Mock 모듈을 생성하기 위해서는 라이브러리 의존성을 추가해야 합니다. mockito, mocktail을 사용할 수 있습니다.

dev_dependencies:
  flutter_test:
    sdk: flutter
  # 둘 중 원하는 것을 선택
  mockito: 
  mocktail:
  build_runner: ^2.4.6

위 라이브러리 중 하나를 통해 간단하게 Mock을 생성할 수 있습니다.

class MockTodoApi extends Mock implements TodoTodoApi {}

위 코드를 통해 우리는 Mock을 생성할 수 있습니다.

그러나, 여전히 TodoRepository는 TodoApi를 직접 참조하고 있기에 Mock을 대신 사용할 수 없습니다. 따라서, 단위 모듈간의 직접 참조 의존성을 제거해야 합니다.

class TodoRepository {
  final TodoApi _api;
  TodoRepository({required TodoApi api}) : _api = api;

  Future<List<Todo>> getTodos() {
    throw UnimplementedError();
  }
}

직접 참조하던 TodoApi를 외부에서 주입할 수 있도록 변경했습니다. 이로 인해 TodoRepository와 TodoApi와의 직접 참조 관계는 사라지고 Mock을 테스트 코드에서 주입이 가능해졌습니다. 이것이 테스트 가능 구조 설계입니다.

class MocktodoApi extends Mock implements TodoApi {}

void main() {
  late MocktodoApi mockTodoApi;
  late TodoRepository todoRepository;
  setUp(() {
    mockTodoApi = MocktodoApi();
    todoRepository = TodoRepository(api: mockTodoApi);
  });

  test('should return todos when getTodos called ', () async {
    // Arrange: getTodos 호출 시 예외 발생하도록 설정
    when(
      () => mockTodoApi.fetchTodoList(),
    ).thenAnswer((_) async => [Todo(todo: 'todo 1')]);
    final todos = await todoRepository.getTodos();
    // Assert
    expect(todos, isA<List<Todo>>());
    expect(todos.length, 1);
    expect(todos.first.todo, 'todo 1');
  });
}

우리는 드디어 첫번째 실패 테스트를 작성할 수 있습니다.

테스트의 대한 신뢰도를 검증했으니 실제 로직을 구현하여 통과시켜보겠습니다.

class TodoRepository {
  final TodoApi _api;
  TodoRepository({required TodoApi api}) : _api = api;

  Future<List<Todo>> getTodos() {
    return _api.fetchTodoList();
  }
}

단순히 메소드 호출로도 충분히 원하는 결과를 가져올 수 있습니다.

테스트가 통과하였으니 리팩토링을 통해 더 간결하게 바꿔줍니다.

class TodoRepository {
  final TodoApi _api;
  TodoRepository({required TodoApi api}) : _api = api;
  /// 람다 표현으로 간결하게 작성
  Future<List<Todo>> getTodos() => _api.fetchTodoList();
}

이렇게 여러 모듈을 참조하는 구조에서 Mocking과 테스트 가능 구조 설계까지 알아보았습니다.

마무리하며

TDD는 코드의 신뢰도를 검증하고 품질 개선과 에러 조기 파악에서 강점을 보이지만, 코드 구현에서 테스트 코드 작성에 대한 시간이 추가로 발생하게 됩니다. 프로젝트 상황에 따라서 TDD를 적용할지 안할지에 대해서 신중하게 판단하여 적용하는 것이 중요하다고 생각합니다.

이후, Widget Test에서 이어가도록 하겠습니다.

profile
자기주도적, 지속 성장하는 모바일앱 개발자의 기록

0개의 댓글