테스팅에 대한 글을 보면 항상 빠지지 않고 나오는 말이 있다.
각각의 테스트는 독립적이어야 한다. 하나의 테스트가 다른 테스트에 영향을 주면 안된다.
근데 실제로 어떤 경우에 테스트가 독립적이지 않게 되고, 그 경우에는 어떻게 리팩토링하여 독립성을 유지할 수 있을까?
테스트 코드 작성이 처음(필자 포함)이라면 사람은 착하게 살아야 한다라는 말 처럼 뭔지는 알겠지만, 너무 막연하게만 느껴진다.
이 글에서는 어떻게 하면 좋은 테스트코드를 작성 할 수 있는지에 대해 실제 예시를 보면서 알아볼 예정이다.
아래 내용은 Unit Testing Best Practices 글과 리팩토링 2판 책의 테스트구축 부분을 참고하였다.
1. 각각의 테스트는 독립적이어야 한다
아래 코드를 살펴 보자.(물론, 너무 극적일 수는 있지만, 단순히 설명을 위해 작성한 테스트 코드임을 인지하자.)
user라는 fixture가 register부분과 find user 의 테스팅 부분에서 두 번 사용되는 것을 볼 수 있다.
이 중복되는 부분을 각각의 it 구문안에 넣는 것이 아니라, 아래처럼 각각의 테스트 블록이 접근 가능한 상위 장소에 놓는다면 중복을 없앨 수 있지 않을까?
describe('AuthService', () => {
let authService: AuthService;
let userService: UsersService;
let user = {
idx: 1,
id: 1,
name: 'test',
email: 'test@test.com',
password: '1234',
current_hashed_refresh_token: 'test either',
role: 2,
is_active: 1,
is_reset_pwd: 0,
created_dt: new Date(),
updatedAt: new Date(),
};
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
providers: [
AuthService,
ConfigService,
{ provide: UsersService, useValue: mockUserService },
{ provide: JwtService, useValue: mockJwtService },
],
}).compile();
authService = moduleRef.get<AuthService>(AuthService);
userService = moduleRef.get<UsersService>(UsersService);
});
//user Fixture를 사용한다.
it('register User', async () => {
mockUserService.create.mockImplementation(() => user);
const { password, ...expectedValue } = user;
expect(await authService.register(user)).toEqual(expectedValue);
});
//user Fixture를 사용한다.
it('find user', async () => {
const hashedPw = await bcrypt.hash(user.password, 10);
user.password = hashedPw;
mockUserService.getById.mockImplementation(() => user);
const result = await authService.vaildateUser(user.id, '1234');
const { password, ...expectedValue } = user;
expect(result).toEqual(expectedValue);
});
})
만약, 중복을 없애기 위해 위 코드처럼 user라는 fixture를 밖으로 빼게 된다면 register User과 find user라는 테스팅코드의 순서에 따라 테스팅 결과가 달라지게 될 것이다. (find user에서 user fixture를 변경하게 되기 때문이다.)
즉, 위의 방식은 테스트끼리 상호작용하게 하는 공유픽스쳐를 만드는 원인이 된다. 즉 독립성이 깨지는 것이다.
너무 극적인가?
그럼, 실제 DB에 user fixture를 등록하고, 해당 유저 정보를 읽어오는 형식의 테스트 두 가지가 존재한다고 생각해보자.
실제 DB에 가상의 user가 등록된다는 사실을 차치하고도, 해당 유저가 등록되는 테스트가 선행되지 않으면, 항상 유저 정보를 읽어오는 테스트는 실패하게 될 것이다. (그리고 한번 더 테스트를 돌린다면 유저 정보를 읽어오는 테스트도 통과 하게 될 것이다.)
이렇게 testing 코드가 주변 환경(DB, testing 실행 순서 등)에 영향을 받지 않고, 같은 코드라면 매번 실행 할 때 마다 주변 환경에 상관 없이 항상 같은 값을 내도록 디자인된 테스트코드가 바로 독립적인 테스팅 코드인 것 이다. 라고 하는 것이다.
그렇다면 위 코드는 어떻게 수정 할 수 있을까?
user 라는 fixture를 현재 위치에서 선언만 하고 beforeEach구문에서 값을 할당 해준다면?
(beforeEach구문은 각각의 테스트블록이 실행되기전에 실행되는 메소드로 초기화에 많이 사용된다. )
describe('AuthService', () => {
let authService: AuthService;
let userService: UsersService;
let user;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
providers: [
AuthService,
ConfigService,
{ provide: UsersService, useValue: mockUserService },
{ provide: JwtService, useValue: mockJwtService },
],
}).compile();
authService = moduleRef.get<AuthService>(AuthService);
userService = moduleRef.get<UsersService>(UsersService);
user = {
idx: 1,
id: 1,
name: 'test',
email: 'test@test.com',
password: '1234',
current_hashed_refresh_token: 'test either',
role: 2,
is_active: 1,
is_reset_pwd: 0,
created_dt: new Date(),
updatedAt: new Date(),
};
});
테스팅 부분은 동일하기에 생략했다. 위와 같은 코드로 우리는 매 테스트 블록마다 user라는 fixture를 초기화 해 줄 수 있게 되었고,
테스팅 순서와는 무관하게 같은 테스팅 결과를 낼 수 있게 됐다.
역시나 테스팅코드의 독립성을 위협했던 DB와의 연결이 필요한 부분은 어떻게 해결 할 수 있을까?
앞서 잠깐 언급했듯이 실제 DB를 사용하면 mockData가 DB에 들어가게 되는 것과 같은 다양한 side-effects가 발생 할 수 있다.
따라서, 이 역시 아래처럼 실제 DB가 아닌, mockDB를 사용하는 것이 바람직하다.
//실제로 DB를 조회 하는 것이 아니라, 이미 찾은 것처럼 user라는 fixture를 return 해주고 있다.
mockUserService.getById.mockImplementation(() => user);
2. 내부 구현 검증 피하기
TDD(Test Driven Development)방식을 이용해 개발을 하다보면 혹은, 기 작성된 코드를 수정하다 보면 수정 내용이 크지 않더라도 테스팅 코드 거의 대부분을 고쳐야 하는 상황이 발생할 수 있다. 이러한 경우는 최종결과를 검증하는 것이 아니라, 보통 내부구현 로직을 검증하려고 하는 경우에 자주 생긴다.
따라서, 내부고현 로직을 하나하나 다 검증하는 것이 아니라, 최종결과를 한번 검증 하는 것이 리팩토링성을 높일 수 있다. (참고링크)
3. 검증부는 하드코딩 하자
(아래의 내용은 향로님의 블로그를 참고하였다.)
export class FilePath {
private readonly _path1: string;
private readonly _path2: string;
private readonly _path3: string;
private readonly _path4: string;
constructor(path1: string, path2: string, path3: string, path4: string) {
this._path1 = path1;
this._path2 = path2;
this._path3 = path3;
this._path4 = path4;
}
get fullPath(): string {
return `C:\\Output Folder\\${[this._path1, this._path2, this._path3, this._path4].join('\\')}`;
}
get path1(): string {
return this._path1;
}
get path2(): string {
return this._path2;
}
get path3(): string {
return this._path3;
}
get path4(): string {
return this._path4;
}
}
하드 코딩된 결과
it('하드 코딩된 결과 검증', () => {
const sut = new FilePath('fields', 'that later', 'determine', 'a folder');
const expected = 'C:\\Output Folder\\fields\\that later\\determine\\a folder';
expect(sut.fullPath).toBe(expected);
});
소프트 코딩된 결과
it('소프트 코딩된 결과 검증', () => {
const sut = new FilePath('fields', 'that later', 'determine', 'a folder');
const expected = `C:\\Output Folder\\${[sut.path1, sut.path2, sut.path3, sut.path4].join('\\')}`;
expect(sut.fullPath).toBe(expected);
});
소프트 코딩을 하게 되면, 복사 붙여넣기 한 것과 다를 바 없다.
무의미한 검증이 된다는 것이다.
그리고 만약, 데이터베이스와 함께 작동하게 되는 코드라면, 기존의 프로덕션 코드와 결합되게 된다(독립성 위배)
위와 같은 문제점 때문에, 테스트 코드의 검증부에서는 하드코딩하는 것이 필요하다.
'테스트코드' 카테고리의 다른 글
[Solved] Jest did not exit one second after the test run has completed. (0) | 2022.08.04 |
---|---|
테스트 DB와 연결하기(feat. Jest with NestJS) (0) | 2022.04.08 |
UnitTest - createQueryBuilder (chained method) mocking하기 (0) | 2022.04.07 |
NestJS에서 테스트코드 작성(feat. Jest, @nestjs/testing) (1) | 2022.03.28 |