Flutter의 단위 테스트: 워크플로 필수 요소에서 복잡한 시나리오까지

게시 됨: 2022-09-21

Flutter에 대한 관심은 사상 최고이며 이미 오래전에 끝났습니다. Google의 오픈 소스 SDK는 Android, iOS, macOS, 웹, Windows 및 Linux와 호환됩니다. 단일 Flutter 코드베이스가 이 모든 것을 지원합니다. 그리고 단위 테스트는 일관되고 안정적인 Flutter 앱을 제공하는 데 중요한 역할을 하며, 어셈블되기 전에 코드 품질을 선제적으로 개선하여 오류, 결함 및 결함을 방지합니다.

이 튜토리얼에서는 Flutter 단위 테스트를 위한 워크플로 최적화를 공유하고 기본 Flutter 단위 테스트를 시연한 다음 더 복잡한 Flutter 테스트 케이스 및 라이브러리로 넘어갑니다.

Flutter의 단위 테스트 흐름

우리는 다른 기술 스택에서 하는 것과 거의 같은 방식으로 Flutter에서 단위 테스트를 구현합니다.

  1. 코드를 평가합니다.
  2. 데이터 조롱을 설정합니다.
  3. 테스트 그룹을 정의합니다.
  4. 각 테스트 그룹에 대한 테스트 기능 서명을 정의합니다.
  5. 테스트를 작성합니다.

단위 테스트를 보여주기 위해 샘플 Flutter 프로젝트를 준비했으며 여가 시간에 코드를 사용하고 테스트할 것을 권장합니다. 이 프로젝트는 외부 API를 사용하여 국가별로 필터링할 수 있는 대학 목록을 가져와 표시합니다.

Flutter 작동 방식에 대한 몇 가지 참고 사항: 프레임워크는 프로젝트가 생성될 때 flutter_test 라이브러리를 자동 로드하여 테스트를 용이하게 합니다. 라이브러리를 통해 Flutter는 단위 테스트를 읽고, 실행하고, 분석할 수 있습니다. Flutter는 테스트를 저장할 test 폴더도 자동 생성합니다. test 폴더의 이름을 바꾸거나 이동하는 것을 피하는 것이 중요합니다. 이렇게 하면 기능이 손상되어 테스트를 실행할 수 없습니다. 또한 테스트 파일 이름에 _test.dart 를 포함하는 것이 중요합니다. 이 접미사는 Flutter가 테스트 파일을 인식하는 방식이기 때문입니다.

테스트 디렉토리 구조

프로젝트에서 단위 테스트를 촉진하기 위해 소스 코드 하위 폴더에 대해 선택한 이름에서 알 수 있듯이 깨끗한 아키텍처와 종속성 주입(DI)을 사용하여 MVVM을 구현했습니다. MVVM과 DI 원칙의 조합은 다음과 같은 문제의 분리를 보장합니다.

  1. 각 프로젝트 클래스는 단일 목표를 지원합니다.
  2. 클래스 내의 각 함수는 자체 범위만 수행합니다.

우리는 우리가 작성할 테스트 파일을 위한 조직화된 저장 공간, 테스트 그룹이 쉽게 식별할 수 있는 "홈"을 가질 시스템을 만들 것입니다. test 폴더 내에서 테스트를 찾아야 하는 Flutter의 요구 사항을 고려하여 test 중인 소스 코드의 폴더 구조를 미러링하겠습니다. 그런 다음 테스트를 작성할 때 적절한 하위 폴더에 저장합니다. 깨끗한 양말이 옷장의 양말 서랍에 들어가고 접힌 셔츠가 셔츠 서랍에 들어가는 것처럼 Model 클래스의 단위 테스트는 model 이라는 폴더에 들어갑니다. , 예를 들어.

lib 및 test라는 두 개의 첫 번째 수준 폴더가 있는 파일 폴더 구조. lib 아래에 중첩된 기능 폴더, 더 많이 중첩된 것은 university_feed, 더 많이 중첩된 것은 데이터입니다. 데이터 폴더에는 리포지토리 및 소스 폴더가 있습니다. 소스 폴더 아래에 중첩된 네트워크 폴더가 있습니다. 네트워크 아래에는 엔드포인트 및 모델 폴더와 university_remote_data_source.dart 파일이 중첩되어 있습니다. 모델 폴더에는 api_university_model.dart 파일이 있습니다. 앞서 언급한 university_feed 폴더와 동일한 수준에 도메인 및 프리젠테이션 폴더가 있습니다. 도메인 아래에 중첩된 것은 유스케이스 폴더입니다. 프레젠테이션 아래에는 모델과 화면 폴더가 중첩되어 있습니다. 앞서 언급한 테스트 폴더의 구조는 lib와 유사합니다. 테스트 폴더 아래에 중첩된 것은 university_feed 폴더를 포함하는 unit_test 폴더입니다. 폴더 구조는 위의 university_feed 폴더와 동일하며 dart 파일에는 이름에 "_test"가 추가됩니다.
소스 코드 구조를 반영하는 프로젝트의 테스트 폴더 구조

이 파일 시스템을 채택하면 프로젝트에 대한 투명성이 구축되고 팀에서 코드의 어느 부분에 관련 테스트가 있는지 쉽게 볼 수 있습니다.

이제 단위 테스트를 실행할 준비가 되었습니다.

간단한 Flutter 단위 테스트

(소스 코드의 data 계층에 있는) model 클래스부터 시작하고 ApiUniversityModel 하나의 model 클래스만 포함하도록 예제를 제한할 것입니다. 이 클래스는 두 가지 기능을 자랑합니다.

  • Map 으로 JSON 객체를 조롱하여 모델을 초기화합니다.
  • University 데이터 모델을 구축합니다.

각 모델의 기능을 테스트하기 위해 앞에서 설명한 범용 단계를 사용자 정의합니다.

  1. 코드를 평가합니다.
  2. 데이터 모의 설정: API 호출에 대한 서버 응답을 정의합니다.
  3. 테스트 그룹 정의: 각 기능에 대해 하나씩 두 개의 테스트 그룹이 있습니다.
  4. 각 테스트 그룹에 대한 테스트 기능 서명을 정의합니다.
  5. 테스트를 작성합니다.

코드를 평가한 후에는 두 번째 목표인 ApiUniversityModel 클래스 내의 두 함수에 특정한 데이터 모의를 설정하는 것을 달성할 준비가 되었습니다.

첫 번째 함수를 모의( Map 으로 JSON을 모의하여 모델 초기화)하기 위해 fromJson 에서 두 개의 Map 객체를 생성하여 함수의 입력 데이터를 시뮬레이션합니다. 또한 제공된 입력으로 함수의 예상 결과를 나타내는 두 개의 동등한 ApiUniversityModel 개체를 생성합니다.

두 번째 함수( University 데이터 모델 구축)인 toDomain 을 모의하기 위해 이전에 인스턴스화 ApiUniversityModel 객체에서 이 함수를 실행한 후 예상되는 결과인 두 개의 University 객체를 생성합니다.

 void main() { Map<String, dynamic> apiUniversityOneAsJson = { "alpha_two_code": "US", "domains": ["marywood.edu"], "country": "United States", "state-province": null, "web_pages": ["http://www.marywood.edu"], "name": "Marywood University" }; ApiUniversityModel expectedApiUniversityOne = ApiUniversityModel( alphaCode: "US", country: "United States", state: null, name: "Marywood University", websites: ["http://www.marywood.edu"], domains: ["marywood.edu"], ); University expectedUniversityOne = University( alphaCode: "US", country: "United States", state: "", name: "Marywood University", websites: ["http://www.marywood.edu"], domains: ["marywood.edu"], ); Map<String, dynamic> apiUniversityTwoAsJson = { "alpha_two_code": "US", "domains": ["lindenwood.edu"], "country": "United States", "state-province":"MJ", "web_pages": null, "name": "Lindenwood University" }; ApiUniversityModel expectedApiUniversityTwo = ApiUniversityModel( alphaCode: "US", country: "United States", state:"MJ", name: "Lindenwood University", websites: null, domains: ["lindenwood.edu"], ); University expectedUniversityTwo = University( alphaCode: "US", country: "United States", state: "MJ", name: "Lindenwood University", websites: [], domains: ["lindenwood.edu"], ); }

다음으로 세 번째 및 네 번째 목표를 위해 테스트 그룹 및 테스트 기능 서명을 정의하는 설명 언어를 추가합니다.

 void main() { // Previous declarations group("Test ApiUniversityModel initialization from JSON", () { test('Test using json one', () {}); test('Test using json two', () {}); }); group("Test ApiUniversityModel toDomain", () { test('Test toDomain using json one', () {}); test('Test toDomain using json two', () {}); }); }

toDomain 함수를 확인하기 위해 두 가지 테스트의 서명을 정의했고 fromJson 함수를 확인하기 위해 두 가지 테스트의 서명을 정의했습니다.

다섯 번째 목표를 달성하고 테스트를 작성하기 위해 flutter_test 라이브러리의 expect 메서드를 사용하여 함수의 결과를 기대치와 비교해 보겠습니다.

 void main() { // Previous declarations group("Test ApiUniversityModel initialization from json", () { test('Test using json one', () { expect(ApiUniversityModel.fromJson(apiUniversityOneAsJson), expectedApiUniversityOne); }); test('Test using json two', () { expect(ApiUniversityModel.fromJson(apiUniversityTwoAsJson), expectedApiUniversityTwo); }); }); group("Test ApiUniversityModel toDomain", () { test('Test toDomain using json one', () { expect(ApiUniversityModel.fromJson(apiUniversityOneAsJson).toDomain(), expectedUniversityOne); }); test('Test toDomain using json two', () { expect(ApiUniversityModel.fromJson(apiUniversityTwoAsJson).toDomain(), expectedUniversityTwo); }); }); }

다섯 가지 목표를 달성했으므로 이제 IDE 또는 명령줄에서 테스트를 실행할 수 있습니다.

5개의 테스트 중 5개가 통과되었음을 나타내는 스크린샷. 헤더 읽기: 실행: api_university_model_test.dart에서 테스트합니다. 화면의 왼쪽 패널은 다음과 같이 읽습니다. 테스트 결과--- api_university_model_test.dart---api_university_model_test.dart---json에서 ApiUniversityModel 초기화 테스트---json one을 사용하여 테스트---json two를 사용하여 테스트---ApiUniversityModel toDomain 테스트 ---json 1을 사용하여 toDomain 테스트---json 2를 사용하여 toDomain 테스트. 화면 오른쪽 패널은 다음과 같이 읽습니다. 통과한 테스트: 5개 중 5개 테스트 ---flutter test test/unit_test/universities_feed/data/source/network/model/api_university_model_test.dart

터미널에서 flutter test 명령을 입력하여 test 폴더에 포함된 모든 테스트를 실행할 수 있으며 테스트가 통과하는지 확인할 수 있습니다.

또는 테스트 또는 테스트 그룹의 이름을 ReplaceWithName 으로 대체하여 flutter test --plain-name "ReplaceWithName" 명령을 입력하여 단일 테스트 또는 테스트 그룹을 실행할 수 있습니다.

Flutter에서 엔드포인트 단위 테스트

종속성 없이 간단한 테스트를 완료했으면 더 흥미로운 예를 살펴보겠습니다. 범위가 다음을 포함하는 endpoint 클래스를 테스트합니다.

  • 서버에 대한 API 호출을 실행합니다.
  • API JSON 응답을 다른 형식으로 변환합니다.

코드를 평가한 후 테스트 그룹 내의 클래스를 초기화하기 위해 flutter_test 라이브러리의 setUp 메서드를 사용합니다.

 group("Test University Endpoint API calls", () { setUp(() { baseUrl = "https://test.url"; dioClient = Dio(BaseOptions()); endpoint = UniversityEndpoint(dioClient, baseUrl: baseUrl); }); }

API에 네트워크 요청을 하려면 필요한 코드 대부분을 생성하는 개조 라이브러리를 사용하는 것을 선호합니다. UniversityEndpoint 클래스를 적절하게 테스트하기 위해 Retrofit 이 API 호출을 실행하는 데 사용하는 dio 라이브러리가 사용자 지정 응답 어댑터를 통해 Dio 클래스의 동작을 조롱하여 원하는 결과를 반환하도록 할 것입니다.

맞춤형 네트워크 인터셉터 모의

DI를 통해 UniversityEndpoint 클래스를 구축했기 때문에 Mocking이 가능합니다. ( UniversityEndpoint 클래스가 Dio 클래스를 자체적으로 초기화한다면 클래스의 동작을 조롱할 방법이 없을 것입니다.)

Dio 클래스의 동작을 모의하려면 Retrofit 라이브러리 내에서 사용되는 Dio 메서드를 알아야 하지만 Dio 에 직접 액세스할 수는 없습니다. 따라서 사용자 지정 네트워크 응답 인터셉터를 사용하여 Dio 를 조롱합니다.

 class DioMockResponsesAdapter extends HttpClientAdapter { final MockAdapterInterceptor interceptor; DioMockResponsesAdapter(this.interceptor); @override void close({bool force = false}) {} @override Future<ResponseBody> fetch(RequestOptions options, Stream<Uint8List>? requestStream, Future? cancelFuture) { if (options.method == interceptor.type.name.toUpperCase() && options.baseUrl == interceptor.uri && options.queryParameters.hasSameElementsAs(interceptor.query) && options.path == interceptor.path) { return Future.value(ResponseBody.fromString( jsonEncode(interceptor.serializableResponse), interceptor.responseCode, headers: { "content-type": ["application/json"] }, )); } return Future.value(ResponseBody.fromString( jsonEncode( {"error": "Request doesn't match the mock interceptor details!"}), -1, statusMessage: "Request doesn't match the mock interceptor details!")); } } enum RequestType { GET, POST, PUT, PATCH, DELETE } class MockAdapterInterceptor { final RequestType type; final String uri; final String path; final Map<String, dynamic> query; final Object serializableResponse; final int responseCode; MockAdapterInterceptor(this.type, this.uri, this.path, this.query, this.serializableResponse, this.responseCode); }

이제 네트워크 응답을 조롱하는 인터셉터를 만들었으므로 테스트 그룹과 테스트 기능 서명을 정의할 수 있습니다.

우리의 경우 테스트할 함수( getUniversitiesByCountry )가 하나만 있으므로 테스트 그룹을 하나만 만듭니다. 다음 세 가지 상황에 대한 함수의 응답을 테스트합니다.

  1. Dio 클래스의 함수는 실제로 getUniversitiesByCountry 에 의해 호출됩니까?
  2. API 요청이 오류를 반환하면 어떻게 됩니까?
  3. API 요청이 예상 결과를 반환하면 어떻게 됩니까?

다음은 테스트 그룹 및 테스트 기능 서명입니다.

 group("Test University Endpoint API calls", () { test('Test endpoint calls dio', () async {}); test('Test endpoint returns error', () async {}); test('Test endpoint calls and returns 2 valid universities', () async {}); });

테스트를 작성할 준비가 되었습니다. 각 테스트 사례에 대해 해당 구성으로 DioMockResponsesAdapter 의 인스턴스를 생성합니다.

 group("Test University Endpoint API calls", () { setUp(() { baseUrl = "https://test.url"; dioClient = Dio(BaseOptions()); endpoint = UniversityEndpoint(dioClient, baseUrl: baseUrl); }); test('Test endpoint calls dio', () async { dioClient.httpClientAdapter = _createMockAdapterForSearchRequest( 200, [], ); var result = await endpoint.getUniversitiesByCountry("us"); expect(result, <ApiUniversityModel>[]); }); test('Test endpoint returns error', () async { dioClient.httpClientAdapter = _createMockAdapterForSearchRequest( 404, {"error": "Not found!"}, ); List<ApiUniversityModel>? response; DioError? error; try { response = await endpoint.getUniversitiesByCountry("us"); } on DioError catch (dioError, _) { error = dioError; } expect(response, null); expect(error?.error, "Http status error [404]"); }); test('Test endpoint calls and returns 2 valid universities', () async { dioClient.httpClientAdapter = _createMockAdapterForSearchRequest( 200, generateTwoValidUniversities(), ); var result = await endpoint.getUniversitiesByCountry("us"); expect(result, expectedTwoValidUniversities()); }); });

이제 끝점 테스트가 완료되었으므로 데이터 소스 클래스인 UniversityRemoteDataSource 를 테스트해 보겠습니다. 이전에 UniversityEndpoint 클래스가 생성자 UniversityRemoteDataSource({UniversityEndpoint? universityEndpoint}) 의 일부라는 것을 관찰했습니다. 이는 UniversityEndpoint UniversityRemoteDataSource 를 사용한다는 것을 나타냅니다. 따라서 이것이 우리가 조롱할 클래스입니다.

Mockito로 조롱하기

이전 예에서 사용자 지정 NetworkInterceptor 를 사용하여 Dio 클라이언트의 요청 어댑터를 수동으로 조롱했습니다. 여기에서 우리는 전체 클래스를 조롱하고 있습니다. 클래스와 해당 함수를 조롱하는 수동 작업은 시간이 많이 소요됩니다. 다행히도 모의 라이브러리는 이러한 상황을 처리하도록 설계되었으며 최소한의 노력으로 모의 클래스를 생성할 수 있습니다. Flutter에서 mocking을 위한 업계 표준 라이브러리인 mockito 라이브러리를 사용해 봅시다.

Mockito 를 통해 모의하려면 먼저 테스트 코드 앞에 void main() {} 함수 바로 위에 " @GenerateMocks([class_1,class_2,…]) " 주석을 추가합니다. 주석에서 클래스 이름 목록을 매개변수로 포함합니다( class_1,class_2… 대신).

다음으로 테스트와 동일한 디렉토리에 모의 클래스에 대한 코드를 생성하는 Flutter의 flutter pub run build_runner build 명령을 실행합니다. 결과 모의 파일의 이름은 테스트 파일 이름과 .mocks.dart 의 조합으로 테스트의 .dart 접미사를 대체합니다. 파일 내용에는 이름이 Mock 접두사로 시작하는 모의 클래스가 포함됩니다. 예를 들어 UniversityEndpointMockUniversityEndpoint 가 됩니다.

이제 university_remote_data_source_test.dart.mocks.dart (모의 파일)를 university_remote_data_source_test.dart (테스트 파일)로 가져옵니다.

그런 다음 setUp 함수에서 MockUniversityEndpoint 를 사용하고 UniversityRemoteDataSource 클래스를 초기화하여 UniversityEndpoint 를 조롱합니다.

 import 'university_remote_data_source_test.mocks.dart'; @GenerateMocks([UniversityEndpoint]) void main() { late UniversityEndpoint endpoint; late UniversityRemoteDataSource dataSource; group("Test function calls", () { setUp(() { endpoint = MockUniversityEndpoint(); dataSource = UniversityRemoteDataSource(universityEndpoint: endpoint); }); }

UniversityEndpoint 를 성공적으로 조롱한 다음 UniversityRemoteDataSource 클래스를 초기화했습니다. 이제 테스트 그룹을 정의하고 함수 서명을 테스트할 준비가 되었습니다.

 group("Test function calls", () { test('Test dataSource calls getUniversitiesByCountry from endpoint', () {}); test('Test dataSource maps getUniversitiesByCountry response to Stream', () {}); test('Test dataSource maps getUniversitiesByCountry response to Stream with error', () {}); });

이를 통해 우리의 조롱, 테스트 그룹 및 테스트 기능 서명이 설정됩니다. 실제 테스트를 작성할 준비가 되었습니다.

첫 번째 테스트는 데이터 소스가 국가 정보 가져오기를 시작할 때 UniversityEndpoint 함수가 호출되는지 확인합니다. 함수가 호출될 때 각 클래스가 어떻게 반응할지 정의하는 것으로 시작합니다. UniversityEndpoint 클래스를 조롱했기 때문에 when( function_that_will_be_called ).then( what_will_be_returned ) 코드 구조를 사용하여 작업할 클래스입니다.

테스트 중인 함수는 비동기식( Future 객체를 반환하는 함수)이므로 when(function name).thenanswer( (_) {modified function result} ) 코드 구조를 사용하여 결과를 수정합니다.

getUniversitiesByCountry 함수가 UniversityEndpoint 클래스 내에서 getUniversitiesByCountry 함수를 호출하는지 확인하기 위해 when(...).thenAnswer( (_) {...} ) 를 사용하여 UniversityEndpoint 클래스 내에서 getUniversitiesByCountry 함수를 조롱합니다.

 when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[]));

이제 응답을 조롱했으므로 데이터 소스 함수를 호출하고 verify 함수를 사용하여 UniversityEndpoint 함수가 호출되었는지 확인합니다.

 test('Test dataSource calls getUniversitiesByCountry from endpoint', () { when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[])); dataSource.getUniversitiesByCountry("test"); verify(endpoint.getUniversitiesByCountry("test")); });

동일한 원칙을 사용하여 함수가 엔드포인트 결과를 관련 데이터 스트림으로 올바르게 변환하는지 확인하는 추가 테스트를 작성할 수 있습니다.

 import 'university_remote_data_source_test.mocks.dart'; @GenerateMocks([UniversityEndpoint]) void main() { late UniversityEndpoint endpoint; late UniversityRemoteDataSource dataSource; group("Test function calls", () { setUp(() { endpoint = MockUniversityEndpoint(); dataSource = UniversityRemoteDataSource(universityEndpoint: endpoint); }); test('Test dataSource calls getUniversitiesByCountry from endpoint', () { when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[])); dataSource.getUniversitiesByCountry("test"); verify(endpoint.getUniversitiesByCountry("test")); }); test('Test dataSource maps getUniversitiesByCountry response to Stream', () { when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[])); expect( dataSource.getUniversitiesByCountry("test"), emitsInOrder([ const AppResult<List<University>>.loading(), const AppResult<List<University>>.data([]) ]), ); }); test( 'Test dataSource maps getUniversitiesByCountry response to Stream with error', () { ApiError mockApiError = ApiError( statusCode: 400, message: "error", errors: null, ); when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.error(mockApiError)); expect( dataSource.getUniversitiesByCountry("test"), emitsInOrder([ const AppResult<List<University>>.loading(), AppResult<List<University>>.apiError(mockApiError) ]), ); }); }); }

우리는 수많은 Flutter 단위 테스트를 실행했으며 다양한 조롱 접근 방식을 시연했습니다. 계속해서 샘플 Flutter 프로젝트를 사용하여 추가 테스트를 실행하도록 초대합니다.

Flutter 단위 테스트: 우수한 UX를 위한 열쇠

단위 테스트를 Flutter 프로젝트에 이미 통합했다면 이 문서에서 워크플로에 주입할 수 있는 몇 가지 새로운 옵션을 소개했을 수 있습니다. 이 튜토리얼에서 우리는 단위 테스트를 다음 Flutter 프로젝트에 통합하는 것이 얼마나 간단한지, 그리고 더 미묘한 테스트 시나리오의 문제를 해결하는 방법을 보여주었습니다. Flutter에서 다시는 단위 테스트를 건너뛰고 싶지 않을 수도 있습니다.

Toptal Engineering Blog의 편집 팀은 이 기사에 제공된 코드 샘플 및 기타 기술 콘텐츠를 검토한 Matija Becirevic과 Paul Hoskins에게 감사의 말을 전합니다.