😎

Clean Architecture TDD 1편 : 외부라이브러리 테스트

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

시작하기 전에,,,

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

사례

3월 에어브릿지를 통해 웹 이벤트 확인 결과,회원가입 수보다 로그인 수가 더 적게 발생하는 이슈가 발생하였습니다.
웹 회원가입 진행 이후 자동 로그인되어, 회원가입 to 로그인이 100%를 넘어야 할 것으로 판단됩니다.

원인

View : 로그인시 로그인이 완료됩니다. AirBridge : 에어브릿지 로그인 이벤트가 감지됩니다.
View : 회원가입시 회원가입 후 자동 로그인이 됩니다. AirBridge : 에어브릿지 회원가입 이벤트가 감지됩니다. -(변경 사항)→ 에어브릿지 회원가입 이벤트가 감지, 자동 로그인의 대응 해야하기 때문에 로그인 이벤트도 감지해야만 합니다.

인수조건

로그인시 Airbridge의 로그인이 감지되어야한다.
회원가입 후 자동로그인 시 Airbridge의 로그인, 회원가입이 모두 감지되어야한다.

테스트 코드

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

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

저는 다음과 같이 선정해보았습니다.
회원가입시 AirBridge 로그인, AirBridge 회원가입 이벤트가 호출되어야한다.
로그인시 AirBridge 로그인 이벤트가 호출되어야한다.
describe('AirBridge 테스트', () => { test('회원가입시 AirBridge의 로그인, 회원가입 이벤트가 호출되어야한다.', () => {}); test('로그인시 AirBridge의 로그인 이벤트만 호출되어야한다.', () => {}); });
TypeScript
복사

2. AirBridge 구현부 살펴보기

테스트 코드를 작성하기 전에 어떤식으로 작성해야 하는지 계획을 짜기 위해서 해당 티켓 작업을 어떻게 구현 했는지 먼저 확인하겠습니다.
LoginUseCase
export class LoginUseCase { static displayName = 'LoginUseCase'; private authService = Inject<AuthService>(AuthService.displayName); private userService = Inject<UserService>(UserService.displayName); login = (email: string, password: string, fromSignUp?: boolean): Promise<boolean> => { ... NCAnalytics.signin({ ... }); ... }); }; }
TypeScript
복사
LoginUseCaselogin 메소드를 살펴보니 로그인 메소드가 호출되는 시점에서, 3번째 인수인 fromSignUp 을 통해, 회원가입 이후 로그인을 호출 한 것인지, 아닌지 판단하고 있네요.
그리고, NCAnalytics 라는 코드로 유추해보았을 경우, 로그인, 회원가입등의 이벤트 감지를 하고, 데이터를 수집하는것 같습니다.
혹시 모르니 NCAnalytics 도 살펴볼게요.
NCAnalytics
export class NCAnalytics { ... // 생략 static signin = (user: { user_id: number; gender: string | undefined }) => { ... }; static signup = (user: { user_id: number; gender: string | undefined }) => { ... }; ... // 생략 static event = (name: string, params: Dictionry = {}) => { GoogleTagManager.event(name, params); ... }; }
TypeScript
복사
NCAnalytics 내부에서 GoogleTagManager 의 코드가 작성되어 있는 것을 확인하였습니다.

3. Jest.spyOn

현실이나 영화 속에서 스파이라는 직업은 “몰래” 정보를 캐내야 합니다. 테스트를 작성할 때도 이처럼, 어떤 객체에 속한 함수의 구현을 가짜로 대체하지 않고, 해당 함수의 호출 여부와 어떻게 호출되었는지만을 알아내야 할 때가 있습니다.
회원가입 버튼을 눌렀을 경우, NCAnalytics가 호출되는가? 에 대한 테스트 코드를 작성해야만 합니다. 회원가입 이벤트를 호출 했을 경우, NCAnalytics의 이벤트가 호출되었느냐, 아니냐만 판단하면 됩니다.
회원가입 버튼을 눌렀을 경우, LoginUseCaselogin 메소드가 실행되었다는 것을 확인했다는 전제로 진행해봅시다.
위 내용을 바탕으로 하여 테스트 코드를 작성해봅시다.
※ 참고 : NCAnalyticsAirBridgeDcode에서 사용하기 편리하게 맵핑한 클래스입니다. (가볍게 AirBridgeNCAnalytics라고 이해해주시면 됩니다.)
describe('AirBridge 테스트', () => { test('회원가입시 AirBridge의 로그인, 회원가입이 모두 호출되어야한다.', async () => { const spySignIn = jest.spyOn(NCAnalytics, 'signin'); const spySignUp = jest.spyOn(NCAnalytics, 'signup'); const loginUseCase = Inject<LoginUseCase>(LoginUseCase.displayName); await loginUseCase.login('test@test.com', '1234', true).then(() => { expect(spySignIn).toBeCalledTimes(1); expect(spySignUp).toBeCalledTimes(1); }); }); test('로그인시 AirBridge의 로그인만 호출되어야한다.', async () => { const spySignIn = jest.spyOn(NCAnalytics, 'signin'); const spySignUp = jest.spyOn(NCAnalytics, 'signup'); const loginUseCase = Inject<LoginUseCase>(LoginUseCase.displayName); await loginUseCase.login('test@test.com', '1234').then(() => { expect(spySignIn).toBeCalledTimes(1); expect(spySignUp).toBeCalledTimes(0); }); }); });
TypeScript
복사

4. Service Locator 등록

LoginUseCase 를 사용하기 위해서, TestCode에 Context를 생성하고 serviceLocatorsingle 패턴으로 등록을 합니다.
실제 코드에서 LoginUseCase에서 로그인 기능이 구현되어져 있습니다.
LoginUseCase 를 테스트코드에서 context를 생성하고 ServiceLocator에 등록해봅시다.
DcodeCommon.initialize((context) => { const serviceLocator = context.getServiceLocator(); serviceLocator.registSingle(LoginUseCase.displayName, () => new LoginUseCase()); });
TypeScript
복사
테스트를 실행해봅시다.
( ...  )

5. Mocking

테스트를 실행 이후, AuthService 를 먼저 등록하라는 에러가 발생합니다...
그래서 다시 LoginUseCase 구현체를 확인해보았는데 무언가 이상합니다.
export class LoginUseCase { static displayName = 'LoginUseCase'; private authService = Inject<AuthService>(AuthService.displayName); private userService = Inject<UserService>(UserService.displayName); login = (email: string, password: string, fromSignUp?: boolean): Promise<boolean> => { ... this.authService.login({ email, password }).then((res) => { ... return this.userService.getUserInfo().then(() => { ...
TypeScript
복사
자체 질문
Q. 저는 “NCAnalytics(=airbridge)에서 회원가입, 로그인이 호출되었는가?” 를 테스트 하고 싶습니다.  A. 그래서 LoginUseCase에서 NCAnalytics가 호출되는지 훔쳐보는 jest.spyOn을 했잖아요.
 Q. 그런데 해당 LoginUseCase코드에서 login 메소드의 결과는 Promise고 선행적으로 authServiceuserService가 의존되어있어요. (구현되어야만 테스트가 가능해요.) 그리고, 저는 테스트코드를 실행하면서 실제로 API로 회원가입, 로그인을 호출 하고 싶지 않아요.  A. 그렇다면 Mocking을 해야만 합니다.
Mocking 이란? mocking은 단위 테스트를 작성할 때, 해당 코드가 의존하는 부분을 가짜(mock)로 대체하는 기법.
React에서는 jest를 이용한 모킹함수들을 이용할 경우, output 결과는 undefined가 되고 내부 로직은 실행하지 않습니다.
예시 (정수만 합산하는 함수)
// original addNum(x : number ,y : number) { if(!Number.isInteger(x)) { throw Error('x는 정수가 아닙니다.') } if(!Number.isInteger(y)) { throw Error('y는 정수가 아닙니다.') } return x + y; } // mock mockAddNum(x : number ,y : number) { return undefined; }
TypeScript
복사
Mocking 방법 2가지
1.
jest에서 제공해주는 함수를 이용하는 방법.
2.
mock 내용 직접 구현.
이번시간에는 2번(mock 내용 직접 구현. ) 으로 한번 진행해보겠습니다.

6-1. AuthService Mock 직접 구현

먼저 regist AuthService first!! 라는 에러를 띄었으니, AuthServicemock으로 만들고 등록해봅시다.
LoginUseCase 에서는 authService를 통하여 login 함수를 사용하고 있네요!
바로 구현해봅시다.
class MockAuthService { login = (loginfo: LoginInfo, type: UserType = UserType.Email): Promise<boolean> => { return Promise.resolve(true); } } ... // 생략 DcodeCommon.initialize((context) => { const serviceLocator = context.getServiceLocator(); serviceLocator.registSingle(AuthService.displayName, () => new MockAuthService()); serviceLocator.registSingle(LoginUseCase.displayName, () => new LoginUseCase()); });
TypeScript
복사
완성되었으면, 다시 테스트를 돌려보아요.
( ...  )

6-2. UserService Mock 직접 구현

이제 regist UserService first!! 라는 에러를 띄었으니, UserServicemock으로 만들고 등록해봅시다.
LoginUseCase 에서는 userService를 통하여 getUserInfo 함수를 사용하고 있네요!
바로 구현해봅시다.
class MockUserService { getUserInfo = (): Promise<UserInfo> => { return Promise.resolve( new UserInfo( ... ); ); } } ... // 생략 DcodeCommon.initialize((context) => { const serviceLocator = context.getServiceLocator(); serviceLocator.registSingle(AuthService.displayName, () => new MockAuthService()); serviceLocator.registSingle(UserService.displayName, () => new MockUserService()); serviceLocator.registSingle(LoginUseCase.displayName, () => new LoginUseCase()); });
TypeScript
복사
완성되었으면, 다시 테스트를 돌려보아요.
(???)
→ LoginUseCase의 30번째 줄을 확인해봅시다.
아 내용을 보아하니, userService에서 userInfo를 불러서 사용하고 있네요!! 그렇다면, userService에 있는 getUserInfouserInfo를 반환하면서 내부적으로 userService에 있는 userInfo값을 업데이트 하는 것으로 유추됩니다.
그럼 테스트 코드를 변경해봅시다.
class MockUserService { userInfo: UserInfo = new UserInfo( ... ); getUserInfo = (): Promise<UserInfo> => { return Promise.resolve(this.userInfo); } }
TypeScript
복사
완성되었으면, 다시 테스트를 돌려보아요.
( ...  )

6-3. RootVM Mock 직접 구현

regist RootVM first!! 에러로 RootVM도 mock으로 구현해봅시다.
LoginUseCase 에서는 RootVM을 inject하여 setUserInfo 함수를 사용하고 있네요!
class MockRootVM { setUserInfo = (userInfo: UserInfo): void => {}; } ... // 생략 DcodeCommon.initialize((context) => { const serviceLocator = context.getServiceLocator(); serviceLocator.registSingle(LoginUseCase.displayName, () => new LoginUseCase()); serviceLocator.registSingle(AuthService.displayName, () => new MockAuthService()); serviceLocator.registSingle(UserService.displayName, () => new MockUserService()); serviceLocator.registSingle(RootVM.displayName, () => new MockRootVM()); });
TypeScript
복사
테스트를 돌려봅시다.
( ...  )

6-4. AuthService Mock ,UserService Mock 내용 추가

userServicebootChannellO 가 존재하네요. LoginUseCase에서 사용하는지 체크해봅시다.
LoginUseCase에서 사용해보니 userServicebootChannellO 말고도 authServiceinitAuthState 도 있군요. 같이 구현해줍시다.
MockAuthService
... //생략 class MockAuthService { login = ( loginInfo: LoginInfo | SnsLoginInfo, type: UserType = UserType.Email ): Promise<boolean> => { return Promise.resolve(true); }; initAuthState = () => {}; } ... //생략
TypeScript
복사
MockUserService
... //생략 class MockUserService { userInfo: UserInfo = new UserInfo( ... ); getUserInfo = (): Promise<UserInfo> => { return Promise.resolve(this.userInfo); }; bootChannelIO = () => {}; } ... //생략
TypeScript
복사
테스트 결과
( 스포 : 진짜 마지막입니다.)

6-5. SentryTracker 추가

마지막으로 SentryTracker를 추가합시다. LoginUseCase에서는 다음과 같이 사용하고 있습니다.
그런데, 정말 다행인것은 SentryTrackerinterface가 존재하네요!
export interface ISentryTracker { setUser( userInfo: { id?: string; email?: string; username?: string; } | null ): void; ... }
TypeScript
복사
interface가 존재한다면 만들기가 엄청 시워집니다. 바로 만들어봅시다.
먼저, 서비스 로케이터에 등록합시다.
DcodeCommon.initialize((context) => { const serviceLocator = context.getServiceLocator(); ... // 생략 serviceLocator.registSingle(SentryTracker.displayName, () => new MockSentryTracker()); });
TypeScript
복사
그 후, 다음과 같이 작성해주세요!
... // 생략 class MockSentryTracker implements ISentryTracker { } ... // 생략
TypeScript
복사
이렇게 작성했다면 다음처럼 에러가 보일겁니다.
JetBrain에서 제공하는 auto implement 기능을 사용합시다. 다른 IDE도 이러한 기능이 분명 있을겁니다.
사용 후 결과(한번에 만들어집니다.)
테스트를 진행해봅시다.
드디어 성공!!!

느낀점

이번 세미나 때는 제가 일부러 리펙토링이 필요한 쪽의 테스트 코드를 예제로 삼았습니다.
interface를 만드는 것과 type을 선언하는 것을 습관화하자.
불필요한 typeinterface는 제외.
불필요한 typeinterface는 오히려 유지보수에 혼동을 줌.
테스트코드를 쉽게 작성하기 위해서는 최대한 작게 쪼개져야만 한다.
메서드, 함수 등등 내부 로직에서 한가지의 일만 처리하자.
에어브릿지의 회원가입 테스트를 진행하기 위해서 너무나도 많은 mock을 생성해야만 합니다. (물론 자동으로 mock을 만들어주는 기능을 jest에서 제공함.)
다음 세미나 때는 jest에서 제공하는 mock 기능을 적극적으로 활용해보도록 하겠습니다.

참고