🙄

Clean Architecture TDD 2편 : Domain & Data

작성자 : 김준혁
사내세미나로 진행한 영상 중 일부입니다. 해당 내용은 실사용 코드가 어느정도 섞여있기 때문에 발표중 일부만 발췌하였습니다.
목차

시작하기 전에,,,

사례 공유를 총 N개로 나누어서 진행할 예정입니다. 하나를 전달하더라도, 확실히 전달하고 싶습니다.
1부 : 외부 라이브러리 테스트 (self constuct mock)
2부 : Architecture TDD (jest mock function) : Data & Domain ( 현재 보시는 문서 위치)
3부 : Architecture TDD : VM

  바쁜이들을 위한 1부 요약 : 외부 라이브러리 테스트 (self constuct mock)

1부의 핵심 : mock을 직접 만들어보기
1부 내용의 일부
위 사진을 보시면, AuthService에 대한 구현을 mock으로 직접 만들고 있습니다.
2부에서는 mock을 직접 만들지 않습니다.
왜? 어떻게? 직접 안만드는지는 알아보겠습니다.

D.CODE Client Architecture

현재 프론트에서 사용 중인 디코드 클라이언트 아키텍쳐는 클린 아키텍쳐를 기반으로 두고 있습니다.
디코드 아키텍쳐를 이용하여 특정 티켓의 작업을 해소 해야만 하는 경우가 있습니다.
사례는 다음과 같습니다.

사례

Santa 에서 사용 중 인 브랜드 목록을 보는 기능을 admin 3.0 으로 이관해야합니다.

인수조건

Santa Brand List GET API를 통하여 브랜드 목록을 조회해오는가?
브랜드 목록이 테이블 표로 나타나는가?
기존 Santa에서 보여지는 브랜드목록과 동일한가?

테스트 코드

테스트는 jest 를 이용합니다. jestReact 프로젝트를 생성할 때 CRA 로 생성했다면 기본적으로 사용이 가능하도록 내부에 라이브러리가 삽입되어있습니다.

1. 인수조건을 기준으로 테스트할 내용을 선정합니다.

현재 총 테스트해야할 곳은 크게 4가지로 예상됩니다.
클린아키텍쳐 기반으로하여 각 영역이 나눠져있습니다.
VIEW- VM - Service - Repository
 : 왜 테스트 해야하는 곳이 4곳이나 되나요?
디코드 아키텍쳐 기반에서는 크게 영역을 3개로 나누었습니다. View(Application) 영역, Domain 영역, Data 영역 입니다.
Domain 과 Data는 각각 최소 1개씩 테스트를 진행해야하며, View영역의 경우 화면 UI와 화면 테스트와 화면 데이터를 다루는 VM을 테스트 해야만 합니다. (총 4개, View-1, VM-1, Domain-1, Data-1)
추가로, 로직이 더 많아지거나 함수별 테스트가 늘어난다면 테스트 파일이나 케이스는 더 늘어날 수 있습니다.
해당 사례에서는 브랜드 목록에 대한 작업이므로 최소 4곳의 테스트를 예상하고 진행하겠습니다. (2부에서는 2개의 영역의 테스트를 다룹니다.)

2-1. Repository 테스트 : 시작

Post Man 확인
회사 Entity라 주석처리합니다.
인수조건 기반으로 테스트 케이스 작성
describe('BrandRepository', () => { test('브랜드 목록 조회 요청시 Get /api/products/brands 를 호출해야한다.', () => {}); }); export {};
TypeScript
복사
brand-repo-start

2-2. Repository 테스트 : 구현부 확인

먼저 실제 구현부를 호출하고 세부 내용을 확인해봅시다.
describe('BrandRepository', () => { test('브랜드 목록 조회 요청시 Get /api/products/brands 를 호출해야한다. ', async () => { await new BrandRepositoryImpl().getBrandListFromSanta(); });
TypeScript
복사
brand-repo-new-impl
실제 레포지토리에서 getBrandListFromSanta 라는 메소드를 호출하는 것을 확인했습니다.
따라서, getBrandListFromSanta의 구현부를 확인해보겠습니다.
getBrandListFromSanta = (): Promise<BrandEntityFromSanta[]> => { return new SantaNetwork().get('/api/products/brands').then((res) => { return res.data; }); };
TypeScript
복사
확인해봤더니, getBrandListFromSanta 에서는 new 로 생성된 인스턴스인 SatnaNetwork에서 get메소드를 호출하고 있네요. 그러면 SantaNetwork를 확인해봅시다.
export class SantaNetwork { private network: NCNetworking = Inject(NCNetworkingForSantaClassName); private execute( ... ): Promise<NCNetworkResponse> { ... } get( ... ) { ... } // ... }
TypeScript
복사
SantaNetworkNCNetorking 타입의 network로부터 의존되고 있네요.

2-3. Repository 테스트 : 테스트 코드 작성

describe('BrandRepository', () => { test('브랜드 목록 조회 요청시 Get /api/products/brands 를 호출해야한다. ', async () => { await new BrandRepositoryImpl().getBrandListFromSanta(); });
TypeScript
복사
위에서 확인 결과, new BrandRepositoryImpl().getBrandListFromSanta()를 실행시키기 위해서는 ServiceLocatorNCNetorking 타입의 network를 사용하는 network 클래스가 등록되어야만 한다는 것을 알 수 있었습니다.
1.
서비스 로케이터를 생성하고 보일러플레이트를 작성합니다.
2.
서비스 로케이터로부터 Network를 Inject 받습니다.
3.
인수조건을 기반으로 예상테스트를 작성해봅니다.
❗️ 예상 테스트는 무엇을 작성 해야할까요?
먼저, 알고 있는 상황부터 정리를 해봅시다.
Santa API 에서 브랜드목록조회 를 1번 호출 하면 원하는 데이터(브랜드목록)를 전달 받는다.
Santa API 에서 브랜드목록조회 를 호출 할 때 필수 파라미터, 바디값이 존재하지 않는다.
로 정리가 되는 것 같습니다.
위 예상으로 작성하면 다음과 같이 됩니다.
describe('BrandRepository', () => { test('브랜드 목록 조회 요청시 Get /api/products/brands 를 호출해야한다. ', async () => { const context = NCApplicationContext.createContext(new AdminApplicationConfiguration()); context.serviceLocator.registSingle(NCNetworkingForSantaClassName, () => new MockSanta()); const network: MockSanta = Inject(NCNetworkingForSantaClassName); await new BrandRepositoryImpl().getBrandListFromSanta(); expect(network.execute).toBeCalledTimes(1); expect(network.execute).toBeCalledWith( NCNetworkMethod.GET, `브랜드목록조회 주소`, {}, undefined, {}, true ); }
TypeScript
복사
brand-repo-context brand-repo-inject brand-repo-new-impl
3번 예상테스트에서 expect().toBeCalledTimes()expect().toBeCalledWith()jest 에서 지원해주는 함수입니다. 각각 몇번 호출되었는지와 어떤 파라미터로 호출했는지를 테스트합니다.
 : Repository는 서버로부터 fetch한 데이터를 받아오는 곳인데 서버로부터 전달받은 데이터가 확실한지 판단해야할 필요가 있지 않나요?
물론 그렇게 작성해도 문제는 없습니다. 또한 그렇게 작성하는 것도 방법 중 하나일 것입니다.
현재 테스트에서는 서버가 전달 해준 interface가 확실하다는 상황 아래에서 테스트 케이스를 작성 중입니다. 만약 서버에게서 약속된 interface로 데이터가 전달되지 않았다면 인터페이스 명세가 잘못되었거나 특정 로직이 잘못됬다고 판단해야할 것 입니다.
추가로, 이미 만들어져있는 API 이고 프론트 측에서만 작업을 하는 것이기 때문에 엔디티 자체를 테스트 해야할 필요는 없다고 판단했습니다.
이제 1부에서 했던 것처럼 mock 데이터를 한번 직접 만들어봅시다. (= new MockSanta )
class MockSanta implements NCNetworking { ... execute( ... ): Promise<NCNetworkResponse> { return Promise.resolve(new NCNetworkResponse(200, '', undefined)); } }
TypeScript
복사
response 결과를 체크하는 것이 아닌, 호출을 몇번했고, 어떤 Param, Body값을 넣었느냐를 테스트 할 것이므로, response에 대한 결과는 임의로 채워서 넣었습니다.
그 후, 테스트 코드를 실행해봅시다.
결과는 receivedmock이나 spy function이어야만 한다. 라고 합니다.
즉, jest에서 제공하는 mock 을 사용할 경우, jest에 손쉽게 제공하는 테스트용 메소드를 사용할 수 있습니다.
여기서 2부의 내용의 핵심인 jest.mock() 에 대해서 설명하고자 합니다.

2-4. Repository 테스트 : jest.mock()

jest 를 이용하여 mock 데이터를 만드는 방법은 많이 있습니다.
그 중 많이 사용하는 import 자체를 mock 화하여 사용해보도록 하겠습니다.
(그 외 mock 화 하는 방법은 jest 공식 홈페이지에 자세히 나열되어 있으니 참고 바랍니다.)
jest.mock('../../../APPLICATION/dependencies/IO/AxiosNetworkingForSanta');
TypeScript
복사
brand-repo-mock-add
사용 방법은 위 코드처럼 사용하고자 하는 코드가 작성된 pathjest.mock()에 추가하면 됩니다.
이렇게 할 경우, 거기서부터 나온 메소드,클래스 자체가 mock 화 됩니다. (변수, 필드멤버는 mock으로 변경되지 않습니다.)

2-5. Repository 테스트 : mockReturnValue()

그러면 위에서 정의한 mock화한 데이터를 사용해봅시다.
describe('BrandRepository', () => { test('브랜드 목록 조회 요청시 Get /api/products/brands 를 호출해야한다. ', async () => { const networking = new AxiosNetworkingForSanta(); const context = NCApplicationContext.createContext(new AdminApplicationConfiguration()); context.serviceLocator.registSingle(NCNetworkingForSantaClassName, () => networking); // ...
TypeScript
복사
brand-repo-mock-create
먼저, 서비스 로케이터에 싱글패턴으로 importAxiosNetworkingForSanta을 등록해야만 합니다.
위 코드에서 이미 mock화가 되어있으므로, 기존에 그냥 new로 생성만 해주면됩니다.
다음으로, mock화 한 메소드는 전부 undefined를 반환하므로 현재상태에서는 테스트가 불가능합니다.
생성된 networkingexecute실행시 반환 결과를 임시로 만들겠습니다.
여기서, 현재 IDE에서는 new AxiosNetworkingForSanta()를 통해 반환된 데이터가 mock 모듈화되어져있는지 모릅니다. 따라서, 강제로 타입을 지정해주겠습니다. 그리고 mockReturnValue 를 이용하여 데이터를 반환할 결과를 추가해봅시다.
그렇게 할 경우 다음처럼 작성됩니다.
describe('BrandRepository', () => { test('브랜드 목록 조회 요청시 Get /api/products/brands 를 호출해야한다. ', async () => { const networking = new (AxiosNetworkingForSanta as jest.Mock)(); networking.execute.mockReturnValue(Promise.resolve(new NCNetworkResponse(200, '', undefined))); const context = NCApplicationContext.createContext(new AdminApplicationConfiguration()); context.serviceLocator.registSingle(NCNetworkingForSantaClassName, () => networking); // ...
TypeScript
복사
brand-repo-mock-returnvalue

2-6. Repository 테스트 : netork 타입 수정

이제 netork 타입을 변경해주겠습니다.
// const network: MockSanta = Inject(NCNetworkingForSantaClassName); const network: NCNetworking = Inject(NCNetworkingForSantaClassName);
TypeScript
복사

2-7. Repository 테스트코드 실행

이제 테스트 코드를 돌려봅시다.
Test Success

2-8. Repository 테스트코드 전체 코드

import { BrandRepositoryImpl } from '../BrandRepository'; import { Inject, NCApplicationContext, NCNetworking, NCNetworkMethod, NCNetworkResponse, } from '@ncodedcode/ncode_react_lib'; import { AdminApplicationConfiguration } from '../../../AdminApplicationConfiguration'; import { AxiosNetworkingForSanta, NCNetworkingForSantaClassName, } from '../../../APPLICATION/dependencies/IO/AxiosNetworkingForSanta'; jest.mock('../../../APPLICATION/dependencies/IO/AxiosNetworkingForSanta'); class MockSanta implements NCNetworking { ... } describe('BrandRepository', () => { test('브랜드 목록 조회 요청시 Get /api/products/brands 를 호출해야한다. ', async () => { const data = new (AxiosNetworkingForSanta as jest.Mock)(); data.execute.mockReturnValue(Promise.resolve(new NCNetworkResponse(200, '', undefined))); const context = NCApplicationContext.createContext(new AdminApplicationConfiguration()); context.serviceLocator.registSingle(NCNetworkingForSantaClassName, () => data); const network: NCNetworking = Inject(NCNetworkingForSantaClassName); await new BrandRepositoryImpl().getBrandListFromSanta(); expect(network.execute).toBeCalledTimes(1); expect(network.execute).toBeCalledWith( NCNetworkMethod.GET, `/api/products/brands`, {}, undefined, {}, true ); }); }); export {};
TypeScript
복사

3-1. Service 테스트 : 시작

현재 BrandService에서는 EntityMapper를 통하여 Model을 반환하고 있습니다.
따라서 테스트 코드는 다음처럼 시작할 수 있을 것 같습니다.
describe('BrandService', () => { test('브랜드 목록 서비스로부터 브랜드목록조회를 요청하면 모델이 도출 되어야한다.'), async () => {} ); });
TypeScript
복사
brand-service-start

3-2. Service 테스트 : 구현부 확인

먼저, 서비스 구현부를 살펴보겠습니다.
//... export class BrandServiceImpl implements BrandService { private readonly brandRepository: BrandRepository; constructor(brandRepository: BrandRepository) { this.brandRepository = brandRepository; } //... /* * Santa 이관작업 * 브랜드 목록 */ getBrandListFromSanta = (): Promise<BrandFromSanta[]> => { ... }; }
TypeScript
복사
저희가 테스트 해야 할 getBrandListFromSanta 의 구현부에서는 brandRepository 를 의존하고 있네요.
따라서, mock화해야할 데이터는 brandRepository 군요.

3-3. Service 테스트 : 서비스로케이터 추가 및 mock 코드 추가

위에서 언급한대로 mock모듈을 추가해보겠습니다.
jest.mock('../../../DATA/repositories/BrandRepository');
TypeScript
복사
brand-service-mock-add
mock화한 Repository를 추가합니다.
jest.mock('../../../DATA/repositories/BrandRepository'); describe('BrandService', () => { test('브랜드 목록 서비스로부터 브랜드목록조회를 요청하면 모델이 도출 되어야한다.'), async () => { const mockRepo = new (BrandRepositoryImpl as jest.Mock)(); // add // ...
TypeScript
복사
brand-service-new-impl
마지막으로 서비스로케이터에 등록해봅시다.
등록할 context는 총 2개입니다.
1.
mock 화한 repository
2.
실제 호출할 service
import { AdminApplicationConfiguration } from 'src/AdminApplicationConfiguration'; import { BrandRepositoryClassName, BrandRepositoryImpl, } from '../../../DATA/repositories/BrandRepository'; import { NCApplicationContext } from '@ncodedcode/ncode_react_lib'; describe('BrandService', () => { test('브랜드 목록 서비스로부터 브랜드목록조회를 요청하면 모델이 도출 되어야한다.'), async () => { const mockRepo = new (BrandRepositoryImpl as jest.Mock)(); const context = NCApplicationContext.createContext(new AdminApplicationConfiguration()); context.serviceLocator.registSingle(BrandRepositoryClassName, () => mockRepo); context.serviceLocator.registSingle( BrandServiceClassName, (c: ServiceLocator) => new BrandServiceImpl(c.resolve(BrandRepositoryClassName)) ); //... }; }); export {};
TypeScript
복사
brand-service-context
Repository에서 mock화 후 구현체의 함수를 이용할 경우, undefined를 반환하다고 설명하였습니다.
따라서, 이번에도 특정 결과를 반환한다는 것을 명시해주어야합니다.
여기서 javascript에서 제공하는 this로 인하여 갭이 생깁니다.
제가 이전에 jest.mock 을 사용할 경우 변수, 필드멤버는 mock으로 변경되지 않는다고 설명했하였습니다.
이는 클래스 내부의 화살표함수도 마찬가지입니다. 화살표함수는 실질적으로 변수에 해당됩니다.
따라서, mock화 되지 않았습니다. 따라서 이번에는 jest.fn() 을 이용하여 mock 으로 만들겠습니다.
코드는 다음과 같습니다.
mockRepo.getBrandListFromSanta = jest.fn().mockReturnValue();
TypeScript
복사
우리는 PostMan 을 통해 어떤 interface 의 데이터가 오는지 알고 있습니다. 그 중 대표적인 타입으로 작성된 2개를 사용하여 mockReturnValue() 에 추가해봅시다.
mockRepo.getBrandListFromSanta = jest.fn().mockReturnValue([ ... ])
TypeScript
복사
brand-service-mock-return & direct drag

3-4. Service 테스트 : BrandService 호출

이제 준비가 끝났습니다.
이제 서비스를 Inject 하여 호출해보겠습니다.
const service: BrandService = Inject(BrandServiceClassName); const result = await service.getBrandListFromSanta();
TypeScript
복사
brand-service-inject

3-5. Service 테스트 : 테스트 코드 작성

실제 어떠한 테스트를 할 것인지 테스트 코드를 작성해보겠습니다.
반환결과는 Model이여야만 합니다.
getBrandListFromSanta으로 호출된 결과는 BrandFromSanta[] 입니다.
그러므로 테스트코드는 다음과 같이 작성됩니다.
1.
배열인가?
expect(result).toBeInstanceOf(Array);
TypeScript
복사
brand-service-testcode-is-array
2.
배열아이템의 타입인가?
result.forEach((r) => { expect(typeof r.id).toBe('number'); ... });
TypeScript
복사
brand-service-testcode-foreach

3-6. Service 테스트코드 실행

이제 테스트 코드를 돌려봅시다.
Test Success

3-7. Service 테스트코드 전체 코드

import TestNameUtils, { TestCaseTypeEnum } from '../../../test-utils/TestNameUtils'; import { Inject, NCApplicationContext, ServiceLocator } from '@ncodedcode/ncode_react_lib'; import { AdminApplicationConfiguration } from '../../../AdminApplicationConfiguration'; import { BrandService, BrandServiceClassName, BrandServiceImpl } from '../BrandService'; import { BrandRepositoryClassName, BrandRepositoryImpl, } from '../../../DATA/repositories/BrandRepository'; jest.mock('../../../DATA/repositories/BrandRepository'); describe('BrandService', () => { test( TestNameUtils.toString( TestCaseTypeEnum.RETURN_VALUE, 'getBrandListFromSanta', '브랜드 목록 조회를 요청하면', 'BrandFromSanta[] 모델이 도출 되어야한다.' ), async () => { const mockRepo = new (BrandRepositoryImpl as jest.Mock)(); mockRepo.getBrandListFromSanta = jest.fn().mockReturnValue( Promise.resolve([ { id: 785, ... }, { id: 786, ... }, ]) ); const context = NCApplicationContext.createContext(new AdminApplicationConfiguration()); context.serviceLocator.registSingle(BrandRepositoryClassName, () => mockRepo); context.serviceLocator.registSingle( BrandServiceClassName, (c: ServiceLocator) => new BrandServiceImpl(c.resolve(BrandRepositoryClassName)) ); const service: BrandService = Inject(BrandServiceClassName); const result = await service.getBrandListFromSanta(); expect(result).toBeInstanceOf(Array); result.forEach((r) => { expect(typeof r.id).toBe('number'); ... }); } ); });
TypeScript
복사

느낀점

jest 를 이용하여 테스트코드를 작성하는 것 자체가 러닝커브가 꽤 있는 작업입니다.
이유는 기존에 사용하던 문법에서 너무나도 다른 단어와 함수들이 나와 숙지하는데 시간이 걸립니다.
추가로, 기능이 수정되었을 경우, 테스트코드도 수정되어야하는 상황이 발생합니다.
이는 이번에 제가 브랜드 작업을 하고난 후 다음 작업자가 브랜드목록에 대한 기능을 추가하셨는데 테스트코드가 수정되어야함을 확인하였습니다.
다음 세미나 때는 프론트에서 어떻게 보면 제일 중요하고 마지막 보루인 View 와 View 데이터를 직접 상태관리하는 VM을 테스트하고자합니다.
추가로, 프론트쪽에서 현재 github에서 배포중인 ncode-react-lib 에 코드를 추가하고 테스트하는 내부 라이브러리 테스트도 진행할 예정입니다.
많관부

참고