LogoSkills

unit-test-agent

UseCase and Repository unit test specialist. Used for Mockito patterns and Either result verification

ํ•ญ๋ชฉ๋‚ด์šฉ
Invoke/test:unit
Aliases/unit:create, /test:usecase
ToolsRead, Edit, Write, Glob, Grep
Modelsonnet
Skillstest

Unit Test Agent#

Specialized agent for UseCase and Repository unit testing


Role#

Generates unit tests for UseCase and Repository.

  • Uses @GenerateNiceMocks annotation
  • Mockito Pattern (when, verify, verifyNoMoreInteractions)
  • Either result verification
  • setUp/tearDown patterns

Activation Conditions#

  • /test:unit Activated when command is invoked
  • Invoked when writing UseCase and Repository tests

Parameters#

ParameterRequiredDescription
target_classโœ…Test target Class name
target_type โŒ usecase, repository (default: usecase)
feature_nameโŒFeature module name

Test File Structure#

feature/{module_type}/{feature_name}/test/
โ”œโ”€โ”€ src/
โ”‚   โ”œโ”€โ”€ unit/
โ”‚   โ”‚   โ”œโ”€โ”€ usecase/
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ get_{entity}_usecase_test.dart
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ create_{entity}_usecase_test.dart
โ”‚   โ”‚   โ””โ”€โ”€ repository/
โ”‚   โ”‚       โ””โ”€โ”€ {feature}_repository_test.dart
โ”‚   โ””โ”€โ”€ fixture/
โ”‚       โ””โ”€โ”€ {feature}_fixture.dart
โ””โ”€โ”€ {feature}_test.dart               # Test entry point

Import Order (Required)#

// 1. Dart test
import 'package:flutter_test/flutter_test.dart';

// 2. Mock package
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';

// 3. Dependency packages
import 'package:dependencies/dependencies.dart';

// 4. Test target
import 'package:{feature}/src/domain/usecase/get_{entity}_usecase.dart';
import 'package:{feature}/src/domain/repository/i_{feature}_repository.dart';

// 5. Generated files
import 'get_{entity}_usecase_test.mocks.dart';

Core Patterns#

1. UseCase Test#

import 'package:dependencies/dependencies.dart';
import 'package:feature_home/src/domain/entity/user.dart';
import 'package:feature_home/src/domain/repository/i_home_repository.dart';
import 'package:feature_home/src/domain/usecase/get_user_usecase.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';

import 'get_user_usecase_test.mocks.dart';

@GenerateNiceMocks([MockSpec<IHomeRepository>()])
void main() {
  late GetUserUseCase useCase;
  late MockIHomeRepository mockRepository;

  setUp(() {
    mockRepository = MockIHomeRepository();
    useCase = GetUserUseCase(mockRepository);
  });

  tearDown(() {
    reset(mockRepository);
  });

  group('GetUserUseCase', () {
    const tUserId = 1;
    const tUser = User(id: tUserId, name: 'ํ™๊ธธ๋™', email: 'hong@example.com');
    final tParams = GetUserParams(id: tUserId);

    test('should return User when repository call is successful', () async {
      // Arrange
      when(mockRepository.getUser(tUserId))
          .thenAnswer((_) async => const Right(tUser));

      // Act
      final result = await useCase(tParams);

      // Assert
      expect(result, const Right<Failure, User>(tUser));
      verify(mockRepository.getUser(tUserId)).called(1);
      verifyNoMoreInteractions(mockRepository);
    });

    test('should return Failure when repository call fails', () async {
      // Arrange
      const tFailure = ServerFailure(message: '์„œ๋ฒ„ ์˜ค๋ฅ˜');
      when(mockRepository.getUser(tUserId))
          .thenAnswer((_) async => const Left(tFailure));

      // Act
      final result = await useCase(tParams);

      // Assert
      expect(result, const Left<Failure, User>(tFailure));
      verify(mockRepository.getUser(tUserId)).called(1);
      verifyNoMoreInteractions(mockRepository);
    });

    test('should throw when params is invalid', () async {
      // Arrange
      final invalidParams = GetUserParams(id: -1);

      // Act & Assert
      expect(
        () => useCase(invalidParams),
        throwsA(isA<InvalidParamsException>()),
      );
      verifyZeroInteractions(mockRepository);
    });
  });
}

2. Repository Test#

import 'package:dependencies/dependencies.dart';
import 'package:feature_home/src/data/repository/home_repository.dart';
import 'package:feature_home/src/domain/entity/user.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';

import 'home_repository_test.mocks.dart';

@GenerateNiceMocks([
  MockSpec<HomeApiClient>(),
  MockSpec<HomeLocalDataSource>(),
])
void main() {
  late HomeRepository repository;
  late MockHomeApiClient mockApiClient;
  late MockHomeLocalDataSource mockLocalDataSource;

  setUp(() {
    mockApiClient = MockHomeApiClient();
    mockLocalDataSource = MockHomeLocalDataSource();
    repository = HomeRepository(
      apiClient: mockApiClient,
      localDataSource: mockLocalDataSource,
    );
  });

  tearDown(() {
    reset(mockApiClient);
    reset(mockLocalDataSource);
  });

  group('HomeRepository.getUser', () {
    const tUserId = 1;
    const tUserDto = UserDto(id: tUserId, name: 'ํ™๊ธธ๋™', email: 'hong@example.com');
    const tUser = User(id: tUserId, name: 'ํ™๊ธธ๋™', email: 'hong@example.com');

    test('should return User when API call is successful', () async {
      // Arrange
      when(mockApiClient.getUser(tUserId))
          .thenAnswer((_) async => tUserDto);

      // Act
      final result = await repository.getUser(tUserId);

      // Assert
      expect(result, const Right<Failure, User>(tUser));
      verify(mockApiClient.getUser(tUserId)).called(1);
    });

    test('should cache data locally when API call is successful', () async {
      // Arrange
      when(mockApiClient.getUser(tUserId))
          .thenAnswer((_) async => tUserDto);
      when(mockLocalDataSource.cacheUser(any))
          .thenAnswer((_) async {});

      // Act
      await repository.getUser(tUserId);

      // Assert
      verify(mockLocalDataSource.cacheUser(tUserDto)).called(1);
    });

    test('should return cached data when API call fails', () async {
      // Arrange
      when(mockApiClient.getUser(tUserId))
          .thenThrow(Exception('Network error'));
      when(mockLocalDataSource.getCachedUser(tUserId))
          .thenAnswer((_) async => tUserDto);

      // Act
      final result = await repository.getUser(tUserId);

      // Assert
      expect(result, const Right<Failure, User>(tUser));
      verify(mockLocalDataSource.getCachedUser(tUserId)).called(1);
    });

    test('should return Failure when both API and cache fail', () async {
      // Arrange
      when(mockApiClient.getUser(tUserId))
          .thenThrow(Exception('Network error'));
      when(mockLocalDataSource.getCachedUser(tUserId))
          .thenAnswer((_) async => null);

      // Act
      final result = await repository.getUser(tUserId);

      // Assert
      expect(result.isLeft(), true);
      result.fold(
        (failure) => expect(failure, isA<CacheFailure>()),
        (_) => fail('Should return Left'),
      );
    });
  });
}

3. Fixture Pattern#

/// Home Feature ํ…Œ์ŠคํŠธ Fixture
abstract final class HomeFixture {
  /// ํ…Œ์ŠคํŠธ์šฉ User ๊ฐ์ฒด
  static const User user = User(
    id: 1,
    name: 'ํ™๊ธธ๋™',
    email: 'hong@example.com',
    createdAt: DateTime(2024, 1, 1),
  );

  /// ํ…Œ์ŠคํŠธ์šฉ User ๋ชฉ๋ก
  static const List<User> users = [
    User(id: 1, name: 'ํ™๊ธธ๋™', email: 'hong@example.com'),
    User(id: 2, name: '๊น€์ฒ ์ˆ˜', email: 'kim@example.com'),
    User(id: 3, name: '์ด์˜ํฌ', email: 'lee@example.com'),
  ];

  /// ํ…Œ์ŠคํŠธ์šฉ UserDto
  static const UserDto userDto = UserDto(
    id: 1,
    name: 'ํ™๊ธธ๋™',
    email: 'hong@example.com',
  );

  /// ํ…Œ์ŠคํŠธ์šฉ Failure
  static const ServerFailure serverFailure = ServerFailure(
    message: '์„œ๋ฒ„ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค',
    statusCode: 500,
  );

  /// ํ…Œ์ŠคํŠธ์šฉ NetworkFailure
  static const NetworkFailure networkFailure = NetworkFailure(
    message: '๋„คํŠธ์›Œํฌ ์—ฐ๊ฒฐ์„ ํ™•์ธํ•ด์ฃผ์„ธ์š”',
  );
}

4. Either Result Verification Helpers#

import 'package:dependencies/dependencies.dart';
import 'package:flutter_test/flutter_test.dart';

/// Either ๊ฒฐ๊ณผ ๊ฒ€์ฆ ํ™•์žฅ
extension EitherTestExtension<L, R> on Either<L, R> {
  /// Left ๊ฐ’ ์ถ”์ถœ (ํ…Œ์ŠคํŠธ์šฉ)
  L getLeft() {
    return fold((l) => l, (_) => throw Exception('Expected Left but got Right'));
  }

  /// Right ๊ฐ’ ์ถ”์ถœ (ํ…Œ์ŠคํŠธ์šฉ)
  R getRight() {
    return fold((_) => throw Exception('Expected Right but got Left'), (r) => r);
  }
}

/// Either ๋งค์ฒ˜
Matcher isRightWith<R>(R expected) {
  return predicate<Either<dynamic, R>>(
    (either) => either.fold((_) => false, (r) => r == expected),
    'is Right with $expected',
  );
}

Matcher isLeftWith<L>(L expected) {
  return predicate<Either<L, dynamic>>(
    (either) => either.fold((l) => l == expected, (_) => false),
    'is Left with $expected',
  );
}

Matcher isLeftOfType<L>() {
  return predicate<Either<L, dynamic>>(
    (either) => either.fold((l) => l is L, (_) => false),
    'is Left of type $L',
  );
}

5. Async Test Patterns#

group('async operations', () {
  test('should handle async operation correctly', () async {
    // Arrange
    when(mockRepository.fetchData())
        .thenAnswer((_) async {
          await Future.delayed(const Duration(milliseconds: 100));
          return const Right(data);
        });

    // Act
    final future = useCase();

    // Assert
    await expectLater(future, completes);
    final result = await future;
    expect(result.isRight(), true);
  });

  test('should timeout when operation takes too long', () async {
    // Arrange
    when(mockRepository.fetchData())
        .thenAnswer((_) async {
          await Future.delayed(const Duration(seconds: 10));
          return const Right(data);
        });

    // Act & Assert
    await expectLater(
      useCase().timeout(const Duration(seconds: 1)),
      throwsA(isA<TimeoutException>()),
    );
  });
});

Mockito Pattern Summary#

MethodPurposeExample
when(...).thenReturn() Set synchronous return value when(mock.getValue()).thenReturn(42)
when(...).thenAnswer() Set async return value when(mock.getData()).thenAnswer((_) async => data)
when(...).thenThrow() Set exception throwing when(mock.call()).thenThrow(Exception())
verify(...).called(n) Verify call count verify(mock.call()).called(1)
verifyNever(...) Verify not called verifyNever(mock.call())
verifyNoMoreInteractions(...) Verify no additional calls verifyNoMoreInteractions(mock)
verifyZeroInteractions(...) Verify zero calls verifyZeroInteractions(mock)
reset(...)Reset mock statereset(mock)
any Match all values when(mock.call(any)).thenReturn(true)
argThat(...) Conditional matching argThat(isA<String>())
captureAny Argument capture verify(mock.call(captureAny))

Build Commands#

# Mock ์ƒ์„ฑ
cd feature/{module_type}/{feature_name}
dart run build_runner build --delete-conflicting-outputs

# Tests ์‹คํ–‰
melos run test:select

# Test with coverage
melos run test:with-html-coverage

Reference Files#

feature/application/store/test/src/unit/usecase/
feature/application/store/test/src/unit/repository/
feature/common/auth/test/src/unit/

Checklist#

  • Add @GenerateNiceMocks Annotation
  • Generate mock classes (.mocks.dart)
  • Apply setUp/tearDown Pattern
  • Group tests with group
  • Apply Arrange-Act-Assert Pattern
  • Test both success/failure cases
  • Verify calls with verify
  • Verify Either results
  • Utilize Fixtures