마스터 고객/제품/창고 테스트 및 UI 구현

This commit is contained in:
JiWoong Sul
2025-09-22 20:30:08 +09:00
parent 5c9de2594a
commit 2d27d1bb5c
41 changed files with 6764 additions and 259 deletions

View File

@@ -0,0 +1,223 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/features/masters/product/domain/entities/product.dart';
import 'package:superport_v2/features/masters/product/domain/repositories/product_repository.dart';
import 'package:superport_v2/features/masters/product/presentation/controllers/product_controller.dart';
import 'package:superport_v2/features/masters/uom/domain/entities/uom.dart';
import 'package:superport_v2/features/masters/uom/domain/repositories/uom_repository.dart';
import 'package:superport_v2/features/masters/vendor/domain/entities/vendor.dart';
import 'package:superport_v2/features/masters/vendor/domain/repositories/vendor_repository.dart';
class _MockProductRepository extends Mock implements ProductRepository {}
class _MockVendorRepository extends Mock implements VendorRepository {}
class _MockUomRepository extends Mock implements UomRepository {}
class _FakeProductInput extends Fake implements ProductInput {}
void main() {
late ProductController controller;
late _MockProductRepository productRepository;
late _MockVendorRepository vendorRepository;
late _MockUomRepository uomRepository;
final sampleProduct = Product(
id: 1,
productCode: 'P-001',
productName: '테스트 제품',
vendor: ProductVendor(id: 10, vendorCode: 'V-10', vendorName: '벤더'),
uom: ProductUom(id: 5, uomName: 'EA'),
);
PaginatedResult<Product> createResult({List<Product>? items}) {
final list = items ?? [sampleProduct];
return PaginatedResult<Product>(
items: list,
page: 1,
pageSize: 20,
total: list.length,
);
}
PaginatedResult<Vendor> createVendorResult() {
return PaginatedResult<Vendor>(
items: [Vendor(id: 10, vendorCode: 'V-10', vendorName: '벤더')],
page: 1,
pageSize: 20,
total: 1,
);
}
PaginatedResult<Uom> createUomResult() {
return PaginatedResult<Uom>(
items: [Uom(id: 5, uomName: 'EA')],
page: 1,
pageSize: 20,
total: 1,
);
}
setUpAll(() {
registerFallbackValue(_FakeProductInput());
});
setUp(() {
productRepository = _MockProductRepository();
vendorRepository = _MockVendorRepository();
uomRepository = _MockUomRepository();
controller = ProductController(
productRepository: productRepository,
vendorRepository: vendorRepository,
uomRepository: uomRepository,
);
});
group('fetch', () {
test('정상 조회 시 결과를 저장한다', () async {
when(
() => productRepository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
vendorId: any(named: 'vendorId'),
uomId: any(named: 'uomId'),
isActive: any(named: 'isActive'),
),
).thenAnswer((_) async => createResult());
await controller.fetch();
expect(controller.result?.items, isNotEmpty);
verify(
() => productRepository.list(
page: 1,
pageSize: 20,
query: null,
vendorId: null,
uomId: null,
isActive: null,
),
).called(1);
});
test('에러 발생 시 errorMessage가 설정된다', () async {
when(
() => productRepository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
vendorId: any(named: 'vendorId'),
uomId: any(named: 'uomId'),
isActive: any(named: 'isActive'),
),
).thenThrow(Exception('fail'));
await controller.fetch();
expect(controller.errorMessage, isNotNull);
});
});
test('loadLookups 호출 시 벤더/단위 목록을 설정한다', () async {
when(
() => vendorRepository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
isActive: any(named: 'isActive'),
),
).thenAnswer((_) async => createVendorResult());
when(
() => uomRepository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
isActive: any(named: 'isActive'),
),
).thenAnswer((_) async => createUomResult());
await controller.loadLookups();
expect(controller.vendorOptions, isNotEmpty);
expect(controller.uomOptions, isNotEmpty);
});
test('쿼리/필터 업데이트 시 상태 반영', () {
controller.updateQuery('abc');
controller.updateVendorFilter(1);
controller.updateUomFilter(2);
controller.updateStatusFilter(ProductStatusFilter.activeOnly);
expect(controller.query, 'abc');
expect(controller.vendorFilter, 1);
expect(controller.uomFilter, 2);
expect(controller.statusFilter, ProductStatusFilter.activeOnly);
});
group('mutations', () {
setUp(() {
when(
() => productRepository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
vendorId: any(named: 'vendorId'),
uomId: any(named: 'uomId'),
isActive: any(named: 'isActive'),
),
).thenAnswer((_) async => createResult());
});
final input = ProductInput(
productCode: 'P-001',
productName: '테스트 제품',
vendorId: 10,
uomId: 5,
);
test('create 성공 시 목록을 갱신한다', () async {
when(
() => productRepository.create(any()),
).thenAnswer((_) async => sampleProduct);
final created = await controller.create(input);
expect(created, isNotNull);
verify(() => productRepository.create(any())).called(1);
});
test('update 성공 시 현재 페이지 유지하며 갱신한다', () async {
when(
() => productRepository.update(any(), any()),
).thenAnswer((_) async => sampleProduct);
final updated = await controller.update(1, input);
expect(updated, isNotNull);
verify(() => productRepository.update(1, any())).called(1);
});
test('delete 성공 시 true 반환 및 갱신', () async {
when(() => productRepository.delete(any())).thenAnswer((_) async {});
final success = await controller.delete(1);
expect(success, isTrue);
verify(() => productRepository.delete(1)).called(1);
});
test('restore 성공 시 갱신', () async {
when(
() => productRepository.restore(any()),
).thenAnswer((_) async => sampleProduct);
final restored = await controller.restore(1);
expect(restored, isNotNull);
verify(() => productRepository.restore(1)).called(1);
});
});
}

View File

@@ -0,0 +1,281 @@
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get_it/get_it.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/features/masters/product/domain/entities/product.dart';
import 'package:superport_v2/features/masters/product/domain/repositories/product_repository.dart';
import 'package:superport_v2/features/masters/product/presentation/pages/product_page.dart';
import 'package:superport_v2/features/masters/uom/domain/entities/uom.dart';
import 'package:superport_v2/features/masters/uom/domain/repositories/uom_repository.dart';
import 'package:superport_v2/features/masters/vendor/domain/entities/vendor.dart';
import 'package:superport_v2/features/masters/vendor/domain/repositories/vendor_repository.dart';
class _MockProductRepository extends Mock implements ProductRepository {}
class _MockVendorRepository extends Mock implements VendorRepository {}
class _MockUomRepository extends Mock implements UomRepository {}
class _FakeProductInput extends Fake implements ProductInput {}
Widget _buildApp(Widget child) {
return MaterialApp(
home: ShadTheme(
data: ShadThemeData(
colorScheme: const ShadSlateColorScheme.light(),
brightness: Brightness.light,
),
child: Scaffold(body: child),
),
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() {
registerFallbackValue(_FakeProductInput());
});
tearDown(() async {
await GetIt.I.reset();
dotenv.clean();
});
testWidgets('플래그 Off 시 스펙 문서 화면을 노출한다', (tester) async {
dotenv.testLoad(fileInput: 'FEATURE_PRODUCTS_ENABLED=false\n');
await tester.pumpWidget(_buildApp(const ProductPage()));
await tester.pump();
expect(find.text('장비 모델(제품) 관리'), findsOneWidget);
expect(find.text('테이블 리스트'), findsOneWidget);
});
group('플래그 On 환경', () {
late _MockProductRepository productRepository;
late _MockVendorRepository vendorRepository;
late _MockUomRepository uomRepository;
setUp(() {
dotenv.testLoad(fileInput: 'FEATURE_PRODUCTS_ENABLED=true\n');
productRepository = _MockProductRepository();
vendorRepository = _MockVendorRepository();
uomRepository = _MockUomRepository();
GetIt.I.registerLazySingleton<ProductRepository>(() => productRepository);
GetIt.I.registerLazySingleton<VendorRepository>(() => vendorRepository);
GetIt.I.registerLazySingleton<UomRepository>(() => uomRepository);
when(
() => vendorRepository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
isActive: any(named: 'isActive'),
),
).thenAnswer(
(_) async => PaginatedResult<Vendor>(
items: [Vendor(id: 1, vendorCode: 'V-001', vendorName: '슈퍼벤더')],
page: 1,
pageSize: 20,
total: 1,
),
);
when(
() => uomRepository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
isActive: any(named: 'isActive'),
),
).thenAnswer(
(_) async => PaginatedResult<Uom>(
items: [Uom(id: 5, uomName: 'EA')],
page: 1,
pageSize: 20,
total: 1,
),
);
});
testWidgets('목록 조회 후 테이블에 표시한다', (tester) async {
when(
() => productRepository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
vendorId: any(named: 'vendorId'),
uomId: any(named: 'uomId'),
isActive: any(named: 'isActive'),
),
).thenAnswer(
(_) async => PaginatedResult<Product>(
items: [
Product(
id: 1,
productCode: 'P-001',
productName: '테스트 제품',
vendor: ProductVendor(
id: 1,
vendorCode: 'V-001',
vendorName: '슈퍼벤더',
),
uom: ProductUom(id: 5, uomName: 'EA'),
),
],
page: 1,
pageSize: 20,
total: 1,
),
);
await tester.pumpWidget(_buildApp(const ProductPage()));
await tester.pumpAndSettle();
expect(find.text('P-001'), findsOneWidget);
verify(
() => productRepository.list(
page: 1,
pageSize: 20,
query: null,
vendorId: null,
uomId: null,
isActive: null,
),
).called(1);
});
testWidgets('폼 검증: 필수값 미입력 시 에러 메시지를 표시한다', (tester) async {
when(
() => productRepository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
vendorId: any(named: 'vendorId'),
uomId: any(named: 'uomId'),
isActive: any(named: 'isActive'),
),
).thenAnswer(
(_) async => PaginatedResult<Product>(
items: const [],
page: 1,
pageSize: 20,
total: 0,
),
);
await tester.pumpWidget(_buildApp(const ProductPage()));
await tester.pumpAndSettle();
await tester.tap(find.text('신규 등록'));
await tester.pumpAndSettle();
await tester.tap(find.text('등록'));
await tester.pump();
expect(find.text('제품코드를 입력하세요.'), findsOneWidget);
expect(find.text('제품명을 입력하세요.'), findsOneWidget);
expect(find.text('제조사를 선택하세요.'), findsOneWidget);
expect(find.text('단위를 선택하세요.'), findsOneWidget);
});
testWidgets('신규 등록 성공 시 repository.create 호출', (tester) async {
var listCallCount = 0;
when(
() => productRepository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
vendorId: any(named: 'vendorId'),
uomId: any(named: 'uomId'),
isActive: any(named: 'isActive'),
),
).thenAnswer((_) async {
listCallCount += 1;
if (listCallCount == 1) {
return PaginatedResult<Product>(
items: const [],
page: 1,
pageSize: 20,
total: 0,
);
}
return PaginatedResult<Product>(
items: [
Product(
id: 99,
productCode: 'NP-001',
productName: '신규 제품',
vendor: ProductVendor(
id: 1,
vendorCode: 'V-001',
vendorName: '슈퍼벤더',
),
uom: ProductUom(id: 5, uomName: 'EA'),
),
],
page: 1,
pageSize: 20,
total: 1,
);
});
ProductInput? capturedInput;
when(() => productRepository.create(any())).thenAnswer((
invocation,
) async {
capturedInput = invocation.positionalArguments.first as ProductInput;
return Product(
id: 99,
productCode: capturedInput!.productCode,
productName: capturedInput!.productName,
vendor: ProductVendor(
id: capturedInput!.vendorId,
vendorCode: 'V',
vendorName: '슈퍼벤더',
),
uom: ProductUom(id: capturedInput!.uomId, uomName: 'EA'),
);
});
await tester.pumpWidget(_buildApp(const ProductPage()));
await tester.pumpAndSettle();
await tester.tap(find.text('신규 등록'));
await tester.pumpAndSettle();
final dialog = find.byType(Dialog);
final editableTexts = find.descendant(
of: dialog,
matching: find.byType(EditableText),
);
await tester.enterText(editableTexts.at(0), 'NP-001');
await tester.enterText(editableTexts.at(1), '신규 제품');
await tester.tap(find.text('제조사를 선택하세요'));
await tester.pumpAndSettle();
await tester.tap(find.text('슈퍼벤더'));
await tester.pumpAndSettle();
await tester.tap(find.text('단위를 선택하세요'));
await tester.pumpAndSettle();
await tester.tap(find.text('EA'));
await tester.pumpAndSettle();
await tester.tap(find.text('등록'));
await tester.pumpAndSettle();
expect(capturedInput, isNotNull);
expect(capturedInput?.productCode, 'NP-001');
expect(find.byType(Dialog), findsNothing);
expect(find.text('NP-001'), findsOneWidget);
verify(() => productRepository.create(any())).called(1);
});
});
}