나도 해보는 e2e 테스트

Yeshin Lee
14 min readSep 14, 2024

--

채용 공고보면 유닛, e2e 같은 테스트 진행 경험을 물어본다. 테스트의 중요성은 이미 알고 있지만, 실제로 작성하는 데는 진입 장벽이 있다. “테스트 코드 짤 시간에 기능을 더 개발하지”라는 생각과 console.log() 디버깅에 익숙해져서다. 하지만 이번 사이드 프로젝트에서 생산성을 고려해 e2e 테스트를 도입해보았다.

Nestjs 프로젝트를 생성하면 기본적으로 root 경로에 /test 폴더가 생성되는데 여기에서 e2e 테스트를 설정, 진행할 수 있다. app.e2e-spec.ts 파일에서 애플리케이션을 초기화하고 테스팅 모듈을 설정하면서 간단한 GET 테스트를 수행한다.

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';

describe('AppController (e2e)', () => {
let app: INestApplication;

beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();

app = moduleFixture.createNestApplication();
await app.init();
});

it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
});

jest-e2e.json은 e2e테스트 관련 설정이 모여있는 파일이다.

{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".", // 이 부분을 주목하자
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

package.jsontest:e2e 스크립트로 e2e 테스트를 실행할 수 있다.

e2e 테스트를 진행할 기능은 다음과 같다.

  • 받는 이의 정보(이메일)로 쪽지를 보낸다.
  • 쪽지를 받았을 때 알람을 보내준다.
async createDm(requestDto: CreateDmDto): Promise<{ dmId: number; receiverId: number }> {
try {
const existReceiver: Users = await this.userRepository.getUserByEmail(requestDto.receiver_email);

if (!existReceiver) {
throw new BadRequestException('받는 사람을 찾을 수 없습니다.');
}

const emotion: Emotions = await this.emotionsRepository.getEmotionByName(requestDto.emotion_name);

if (!emotion) {
throw new BadRequestException('감정을 찾을 수 없습니다.');
}

const newDm = await this.directMessageRepository.createDm(requestDto.sender_id, existReceiver.id, emotion.id, requestDto.content);

// 상대방에게 쪽지 도착 알림 전송
const notificationMessage = `새로운 쪽지가 도착했습니다: ${newDm.id}(보낸 사람 아이디: ${requestDto.sender_id})`;

this.directMessageGateway.sendNotificationToUser(existReceiver.id, notificationMessage);

return { dmId: newDm.id, receiverId: existReceiver.id };
} catch (error) {
throw error;
}
}

첫번째 기능에 대한 e2e 테스트는 다음과 같이 설계해보았다.

it('/direct-messages (POST)', (done) => {
request(app.getHttpServer())
.post('/direct-messages')
.send({ sender_id: 1, receiver_email: 'testuser2@test.com', emotion_name: '응원과 감사', content: '토이 3팀 손절보안관 울트라 캡숑 짱짱' })
.expect(201)
.end((err, res) => {
if (err) return done(err);
expect(res.body.message).toBe('쪽지 전송 성공');
done();
}
}

Cannot find module ‘@modules/health/health.module’ from ‘../src/app.module.ts’

AppModule에 선언된 모듈부터 불러올 수 없다니?

AppModule에서 불러오는 module의 경로를 클릭하면 해당 모듈로 이동해서 ‘뭐지?’ 싶었다.

@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
validationSchema: validateEnv(),
}),
HealthModule,
CommonModule,
RouterModule.register(),
],
controllers: [AppController],
providers: [AppService],
})

export class AppModule {}

경로 별칭(@)은 tsconfig.jsonpaths에 선언되어 있지만, Jest는 이 경로 별칭을 인식하지 못한다고 한다. Jest 설정 파일에 moduleNameMapper를 추가해 경로를 제대로 읽을수 있도록 설정했다.

"moduleNameMapper": {
"^@configs/(.*)$": "<rootDir>/src/configs/$1",
"^@common/(.*)$": "<rootDir>/src/common/$1",
"^@router/(.*)$": "<rootDir>/src/router/$1",
"^@v1/(.*)$": "<rootDir>/src/modules/v1/$1",
"^@modules/(.*)$": "<rootDir>/src/modules/$1",
"^@entities/(.*)$": "<rootDir>/src/entities/$1",
"^@repositories/(.*)$": "<rootDir>/src/repositories/$1",
"^@gateways/(.*)$": "<rootDir>/src/gateways/$1",
"^@constants/(.*)$": "<rootDir>/src/constants/$1"
}

(지금 생각해보면 해당 케이스가 흔하지 않겠지만) Jest에서 캐시 문제로 오류가 발생할 수도 있다고 하여 package.json에 캐시 삭제 명령어 script를 추가해두었다. 스크립트 이름이 길지만, 커맨드 내용이 담기는게 중요하다고 생각해서 일단 저렇게 썼다.

"test:e2e:cache:clear": "jest --clearCache"

그럼에도 여전히 모듈 명만 다르고(healthModule) 같은 종류의 에러가 발생했다. 모듈 경로를 설정했음에도 모듈을 찾지 못하길래 폴더 구조를 확인해보니, module 폴더 안에 다양한 하위 폴더들로 인해 healthModule 경로를 제대로 매핑하지 못한 거였다. 그래서 healthModule 경로를 추가했지만 여전히 에러는 반복되었다.

에러 메시지를 다시 보니, 매핑된 경로가 이상했다.

User/[이름]/keep-in-touch-be-src/test/src/~

‘test 폴더에서 왜 src 파일을 찾지?’ 싶다가 순간 jest-e2e.json 파일의 rootDir 옵션이 생각났다. “rootDir”: “.”가 문제였다. 각 모듈의 경로를 rootDir에서 상위 디렉토리로 이동하도록 수정했다.

"moduleNameMapper": {
"^@configs/(.*)$": "<rootDir>/../src/configs/$1",
"^@common/(.*)$": "<rootDir>/../src/common/$1",
"^@router/(.*)$": "<rootDir>/../src/router/$1",
"^@health/(.*)$": "<rootDir>/../src/modules/health/$1",
"^@v1/(.*)$": "<rootDir>/../src/modules/v1/$1",
"^@modules/(.*)$": "<rootDir>/../src/modules/$1",
"^@entities/(.*)$": "<rootDir>/../src/entities/$1",
"^@repositories/(.*)$": "<rootDir>/../src/repositories/$1",
"^@constants/(.*)$": "<rootDir>/../src/constants/$1"
}

이후에 gateway 관련 경로 문제가 발생해서 moduleNameMapper와 provider에 추가하면서 해결했다.

Jest did not exit one second after the test run has completed.

Jest에서는 이미 테스트가 끝났는데 종료되지 않은 비동기 작업이 있어 발생한 에러로 친절하게 부가 설명이 달린다.

This usually means that there are asynchronous operations that weren’t stopped in your tests. Consider running Jest with — detectOpenHandles to troubleshoot this issue.

알람 기능 때문에 webSocket을 사용했는데, 테스트가 종료되고 나서 즉 afterAll에서 socket.close()로 소켓을 닫았다. socket이 열려있을 때의 조건을 추가하고 close 대신 disconnect 를 사용하는 것으로 해결했다. disconnect는 사용자가 의도적으로 연결을 끊고 싶은 경우 사용하는데, socketIO의 자동 재연결이 활성화되어 있어도 연결을 끊는다고 한다. 테스트라서 괜찮나? 싶었지만 일단 이렇게 처리했다.

afterAll(async () => {
if (socket) {
socket.disconnect()
}

app.close();
})

Socket.IO 1.x버전에서는 close가 disconnect 역할이었는데, 2.x버전부터는 disconnect가 공식적인 연결 해제 메서드가 되었다.

아직 열려있는 비동기 작업은 — — detectOpenHandles 플래그로 확인할 수 있다.

매번 입력하기 귀찮아서 명령어 스크립트에 추가해놨었는데, 성능에 영향을 미쳐서 디버깅 용으로만 사용해야한다는 문구를 보고 다시 지웠다.

expected 201 “Created”, got 404 “Not Found”

비록 404 에러가 발생하긴 했지만, e2e 테스트가 실행되기는 했다.

예상 지점에 console.log도 찍어보고 에러 케이스의 exception 인자도 바꿔봤지만 변한 것은 없었다. 알고 보니, 404 에러는 내가 설정한 에러가 아닌 해당 엔드포인트를 찾지 못해서 발생한 것이었다. Controller 데코레이터 내에 경로를 제대로 설정했는데 왜 못읽지 싶어 이리 저리 찾아보다가 라우터 모듈 파일을 보았다.

{
path: '/v1',
module: DirectMessagesModule,
},

테스트할 API 경로에 v1을 빼먹은 것이다.

그 외에도 자잘한 상황을 마주했다.

  • entity 데코레이터 값이 실제 데이터베이스 명과 다른 경우
  • 엔티티 필드명 오타 혹은 nullable 미설정으로 null 값이 들어간 경우

Cannot log after tests are done. Did you forget to wait for something async in your test?

위 에러와 비슷하게 테스트가 종료됐는데 비동기 작업이 완료되지 않아 발생했다. 원인은 크게 2가지로 볼 수 있다.

  • 2개 이상의 비동기 요청이 충돌하는 경우
  • done 콜백이 제대로 호출되지 않은 경우

done 콜백은 Promiseresolve처럼 비동기 작업이 완료됐다는 걸 명시적으로 알려 준다. 결국 request 가 끝난 후에 done()을 호출하여 테스트가 완료됐다는걸 알려야 한다. 처음에는 ‘end 뒤에 done()을 붙이면 되겠네~’라고 생각했는데..

.end() was called twice. This is not supported in superagent

Superagent는 Node.js에서 사용하는 HTTP 요청 라이브러리로 end를 두 번 호출하는 것을 지원하지 않는다. end 대신 then을 사용하는 방법도 있고, request 자체가 비동기 작업이라 Promise 기반으로 처리하면 되겠다 싶어 진행했는데 다른 문제가 발생했. done 대신 async, await, promiseresolvereject를 사용했는데, 테스트가 종료되고 나서 무한 루프가 도는 것이었다. 비동기 작업이 계속해서 종료되지 않았던 건데, 알고보니 Promise 문제보다 다른 원인이었다.

그 원인은 소켓이다. 각 스코프 내에 console.log를 찍어보니, 앱을 닫았는데도 무한루프가 도는 것이다. disconnect로 소켓을 닫지만 추가 비동기 작업이 남아있을 수 있다고 하여 socket이 연결된 경우에만 disconnect를 하는 것으로 조건을 추가했다.

if (socket && socket.connected) {
console.log('소켓 열려있음');

socket.on('disconnect', () => {
console.log('소켓 정상적으로 닫힘');
});

socket.disconnect();
} else {
console.log('이미 소켓 끊김');
}

console.log('앱 닫기');
await app.close();
});
}

이렇게 수정하고 테스트를 진행했지만, 쪽지를 보낸 후에도 notification 방에 들어가지 못하고 소켓이 연결이 해제되었다.

notification 방에 들어가지 못한 문제에 대해 찾아보다가 급 지쳐서 해결하지 못했다. 이 부분은 좀 더 찾아보고 나서 다시 정리해봐야겠다.

알람 기능은 우선순위에 밀려 2차 개발 기간에 진행하는 것으로 결정되었다.

--

--

Yeshin Lee
Yeshin Lee

No responses yet