Test Driven Developent, 즉 테스트 주도 개발은 필수 사항은 아니지만 테스트 코드를 작성함으로써 성공 및 실패 케이스를 모두 검증하여 우리의 어플리케이션이 원하는 대로 동작할 것이라는 자신감을 가질 수 있게 해준다. 실패하는 테스트 코드를 먼저 작성해두고 실제로 개발하면서 이 테스트에 통과할 수 있는 방향으로 코드를 작성하는 작업을 반복한다. 이렇게 해서 모든 테스트를 통과하면 리팩토링을 거쳐 이후에도 테스트에 성공할 수 있도록 만든다. 테스트 코드를 함께 레포에 merge하면 개발자들 간의 테스트 커버리지를 실시간으로 확인할 수 있기 때문에 문서화의 효과도 가진다. 이번 프로젝트에서는 자바스크립트의 테스트 프레임워크인 JEST 라이브러리를 사용하였다. API 문서에 설명이 잘 나와있으며 별도의 라이브러리만 추가하면 Typescript 환경에서도 지원이 된다는 점에서 매우 편리했다. 단위 테스트를 할 때에는 JEST가 제공하는 기능 중 모듈의 전체 혹은 일부를 mocking 할 수도 있다. 테스트 코드를 작성할 때 어떤 한 모듈이 내부적으로 사용하는, 즉 import 해오는 의존성이 있다면 그 외부 모듈 전체 혹은 부분을 테스트 코드에서 mock해주는 것인데(실제로 내가 처음에 사용한 방법이다.) 이는 모듈 간 의존성의 decoupling을 저해하고 부분적으로만 모듈의 기능들을 mocking 해야할 때에는 테스트 코드를 구현하는 것 역시 쉽지 않았다. 따라서 대안적으로 Stub의 방식을 채택해 리팩토링한 코드를 추가적으로 첨부하여 어떻게 나쁜 코드의 예제를 바꾸었는지의 과정을 보여줄 것이다. Stub은 개발 코드에서 외부 모듈을 import하는 것이 아닌 생성자 함수를 통해 주입받는 것이고 테스트 코드에서는 테스트 용으로 별도로 만들어 놓은 클래스의 객체를 주입받거나 빈 object를 주입받아 필요할 때마다 내부적으로 프로퍼티 메소드를 정의해 주는 것이다. 후자는 순수 자바스크립트에서만 가능한 방법으로 나는 테스트 폴더에 따로 Stub 클래스를 생성해주었다. 결국 테스트 코드를 작성하다 보면 모듈의 의존성을 고려하게 되어 개발 코드의 퀄리티를 향상시킬 수 있다.

Essential things to do before we start

타입스크립트로 Jest 라이브러리를 이용해 단위 테스트와 통합 테스트를 하기 위해서 추가해야하는 외부 의존성들은 다음과 같다.

  • jest
  • ts-jest, @types/jest
  • node-mocks-http (단위 테스트를 할 때 가짜 http 요청 및 응답 객체를 생성할 수 있게 해줌)
  • faker@5.5.3 (단위 테스트를 할 때 가짜 유저의 정보 등 mocking할 때 유용한 라이브러리, 현재 버전은 잘 작동하지 않아 이전 버전을 설치했음)
  • axios(통합 테스트를 할 때 직접 서버에다가 http 요청을 하여 프로미스 형태의 응답을 받아올 수 있게 해줌)

또한 jest는 es6의 모듈과 잘 동작하지 않기 때문에 commonjs로 변환해주는 바벨 플러그인을 설치해 준 후 프로젝트 최상위 폴더 내부의 .babelrc 파일에 아래의 코드를 작성해주어야 한다.

npm i –save-dev @babel/plugin-transform-modules-commonjs

{
	"env": {
		"test": {
			"plugins": ["@babel/plugin-transform-modules-commonjs"]
		}
	}
}

타입스크립트로 정의된 모든 test파일 내부에서 모듈을 import할 때에는 extension에 .js를 붙일 수 없다. extension을 생략하고 npm start를 하면 es6 모듈로 인식되는 자바스크립트 파일로 컴파일 된 후 서버를 실행시키에 오류가 발생하는데, commonjs 모듈과 달리 es6 모듈은 extension이 빠지면 안되기 때문이다. 따라서 jest.config.mjs에서 다음과 같은 추가적인 설정을 해줘야 한다. 참고로 jest –init 명령어를 입력하면 jest.config.mjs 파일을 생성할 수 있다.

{
	"extensionsToTreatAsEsm": [".ts"],
	"moduleNameMapper": {
		"^(\\.{1,2}/.*)\\.js$": "$1"
	},
	"transform": {
		"^.+\\.tsx?$": [
			"ts-jest",
			{
				"useESM": true
			}
		]
	}
}

그 다음은 테스트용 환경 변수 파일을 따로 만들어주는 것이다. .env.test 파일에 process.env에서 로드할 key와 value의 쌍들을 모두 적어주고 jest.config.mjs 파일의 setupFiles의 배열에 ‘dotenv/config’를 추가해준다. 이는 각각의 테스트 이전의 dotenv 모듈의 config 메소드를 실행하게 해준다. 그리고 package.json의 npm 명령어 정의 부분에서 테스트 종류 별로 테스트용 환경 변수 파일 위치를 명시해주는 DOTENV_CONFIG_PATH 옵션을 설정해준다. 윈도우의 경우 dev 모드로 cross-env를 설치한 후 명령어 앞 부분에 추가해주어야 정상적으로 작동한다.

"scripts": {
    "test": "cross-env DOTENV_CONFIG_PATH=./.env.test jest --watchAll --verbose --globalTeardown=./src/tests/global_teardown.ts",
    "test:unit": "cross-env DOTENV_CONFIG_PATH=./.env.test jest --watchAll --verbose --testPathIgnorePatterns /src/tests /dist",
    "test:integration": "cross-env DOTENV_CONFIG_PATH=./.env.test jest --watchAll --verbose --testPathPattern=/src/tests --globalTeardown=./src/tests/global_teardown.ts",
    "start": "concurrently \"tsc -w\" nodemon dist/index"
  }

여기서 내가 사용한 몇 가지 옵션들에 대해 살펴보자. –watchAll은 테스트 파일에 변경사항이 생길 때마다 모든 테스트 파일을 다시 재실행하도록 해준다. –verbose는 실행된 테스트 파일별로 계층에 따라 개별적인 테스트의 결과(describe 혹은 test의 첫번째 인자로 넘겨주는 string 값)들을 출력해준다. –globalTeardown은 모든 테스트의 실행을 마친 후 데이터베이스 정리 작업을 해주는 등의 작업을 실행할 파일을 정의해준다. 단위 테스트에서는 db에 직접 접근하지 않기 때문에 tear down 작업을 해 줄 필요가 없다.

Unit Test

유닛테스트는 하나의 함수, 모듈 혹은 클래스가 원하는 대로 동작하는 지를 검증하는 것이다. 예를 들어, User API 요청을 처리하는 각각의 서비스 별 controller의 실행 결과에 대해 성공과 실패 케이스 모두 테스트 코드를 작성할 수 있다.

먼저 테스트의 대상이 되는 user.ts 파일의 코드를 살펴보자.

/src/controller/user.ts

import { Request, Response, NextFunction } from 'express';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import * as UserRepository from '../data/user.js';
import * as BugsRepository from '../data/bugs.js';
import * as CompanyRepository from '../data/companies.js';
import * as ProductRepository from '../data/products.js';
import { config } from '../config.js';
import { UserRegistration, UserLogin, ProductItem } from '../types/index.js';

export const createUser = async (
	req: Request,
	res: Response,
	next: NextFunction
) => {
	const { userName, password } = req.body as UserRegistration;
	const user = await UserRepository.findUserByName(userName);
	if (user) {
		return res.sendStatus(409);
	}

	const hashed = await bcrypt.hash(
		password,
		parseInt(config.bcrypt.saltsRound)
	);
	const userId = await UserRepository.createUser({
		...req.body,
		password: hashed,
	});

	const token = createJWT(userId);

	res.status(201).json({
		token,
		userName,
	});
}

export const login = async (req: Request, res: Response, next: NextFunction) => {
	const { userName, password } = req.body as UserLogin;
	const user = await UserRepository.findUserByName(userName);
	if (!user) return res.sendStatus(401);

	const match = await bcrypt.compare(password, user.password);
	if (!match) return res.sendStatus(401);

	const token = createJWT(user.id);

	res.status(200).json({
		token,
		userName,
	});
}

export async function remove(req: Request, res: Response, next: NextFunction) {
	await UserRepository.deleteUser(req.userId!);
	res.sendStatus(204);
}

export async function getMyPage(
	req: Request,
	res: Response,
	next: NextFunction
) {
	const { findUserById } = UserRepository;
	const {
		getNumberOfReservationsOfUser,
		getNumberOfInterestedCompaniesOfUser,
		getReservationItemsOfUser,
	} = CompanyRepository;
	const { getSurveyItemsOfUser } = BugsRepository;
	const { getProductItemsOfUser } = ProductRepository;

	const user = await findUserById(req.userId!);
	const accumulatedNumOfUsages = await getNumberOfReservationsOfUser(
		req.userId!
	);
	const numberOfInterestedCompanies =
		await getNumberOfInterestedCompaniesOfUser(req.userId!);
	const surveyList = await getSurveyItemsOfUser(req.userId!);
	const productList = await getProductItemsOfUser(req.userId!);
	const reservationList = await getReservationItemsOfUser(req.userId!);

	let updatedProductList;
	await Promise.all(
		productList.map(async (product) => {
			return updateNumOfInterestedUsers(product);
		})
	).then((result) => (updatedProductList = result));

	const userDetail = {
		...user,
		accumulatedNumOfUsages,
		numberOfInterestedCompanies,
		surveyList,
		updatedProductList,
		reservationList,
	};

	res.status(200).json(userDetail);
}

export function createJWT(userId: number): string {
	return jwt.sign({ userId }, config.jwt.privateKey, {
		expiresIn: config.jwt.expirSecs,
	});
}

async function updateNumOfInterestedUsers(
	product: ProductItem
): Promise<ProductItem> {
	const numOfUsers =
		await ProductRepository.getNumberOfInterestedUsersOfProduct(
			product.productId.toString()
		);
	product.numOfInterestedUsers = numOfUsers;
	return product;
}

위 코드에서 export 하는 컨트롤러 함수들은 각각 ‘회원가입’, ‘로그인’, ‘회원탈퇴’, ‘마이페이지 정보 불러오기’를 위한 로직과 관련되어 있다. 여기서 눈 여겨 볼 것은 사용자, 벌레, 회사, 상품 db에 각각 접근하는 repository의 모듈들에 의존하고 있다는 사실이다. 위의 코드를 테스트 하기 위해서는 테스트 코드에서 각각의 모듈들을 jest.mock을 이용해 mocking 해야만 정상적으로 검증을 할 수 있다.

/src/controller/test/user.test.ts

import httpMocks from 'node-mocks-http';
import { faker } from '@faker-js/faker';
import * as bcrypt from 'bcrypt';
import * as userRepository from '../../data/user.js';
import * as companiesRepository from '../../data/companies.js';
import * as productsRepository from '../../data/products.js';
import * as bugsRepository from '../../data/bugs.js';
import { createUser, getMyPage, login, remove } from '../../controller/user.js';

jest.mock('bcrypt');
jest.mock('../../data/user');
jest.mock('../../data/companies');
jest.mock('../../data/products', () => {
	const originalModule = jest.requireActual('../../data/products');

	return {
		__esModule: true,
		...originalModule,
		getNumberOfInterestedUsersOfProduct: jest.fn(async () => '1'),
	};
});
jest.mock('../../data/bugs');

describe('User Controller', () => {
	it('create user', async () => {
		const registrationForm = {
			userName: faker.name.firstName(),
			password: faker.internet.password(),
			name: faker.name.fullName(),
			contactNumbers: faker.phone.number(),
			email: faker.internet.email(),
		};
		const request = httpMocks.createRequest({
			body: registrationForm,
		});
		const response = httpMocks.createResponse();
		const next = jest.fn();

		jest
			.spyOn(userRepository, 'findUserByName')
			.mockImplementation(async () => undefined);

		jest.spyOn(userRepository, 'createUser').mockImplementation(async () => 1);

		await createUser(request, response, next);

		expect(response.statusCode).toBe(201);
		expect(response._getJSONData()).toMatchObject({
			userName: request.body.userName,
		});
		expect(response._getJSONData().token).toBeDefined();
	});

	it('return 409 if user already exists', async () => {
		const userName = faker.name.firstName();
		const password = faker.internet.password();
		const user = {
			id: faker.datatype.number(),
			userName,
			password,
			name: faker.name.fullName(),
			contactNumbers: faker.phone.number(),
			email: faker.internet.email(),
		};
		const registrationForm = {
			userName,
			password,
			name: faker.name.fullName(),
			contactNumbers: faker.phone.number(),
			email: faker.internet.email(),
		};
		const request = httpMocks.createRequest({
			body: registrationForm,
		});
		const response = httpMocks.createResponse();
		const next = jest.fn();

		jest
			.spyOn(userRepository, 'findUserByName')
			.mockImplementation(async () => user);

		await createUser(request, response, next);

		expect(response.statusCode).toBe(409);
	});

	it('succeed to login', async () => {
		const userName = faker.name.firstName();
		const password = faker.internet.password();

		const user = {
			userName,
			password,
			id: faker.datatype.number(),
			name: faker.name.fullName(),
			contactNumbers: faker.phone.number(),
			email: faker.internet.email(),
		};
		const request = httpMocks.createRequest({
			body: {
				userName,
				password,
			},
		});
		const response = httpMocks.createResponse();
		const next = jest.fn();

		jest
			.spyOn(userRepository, 'findUserByName')
			.mockImplementation(async () => user);

		jest.spyOn(bcrypt, 'compare').mockImplementation(async () => true);

		await login(request, response, next);

		expect(response.statusCode).toBe(200);
		expect(response._getJSONData()).toMatchObject({
			userName: request.body.userName,
		});
		expect(response._getJSONData().token).toBeDefined();
	});

	it('return 401 if user does not exist', async () => {
		const request = httpMocks.createRequest({
			userName: faker.name.firstName(),
			password: faker.internet.password(),
		});
		const response = httpMocks.createResponse();
		const next = jest.fn();

		jest
			.spyOn(userRepository, 'findUserByName')
			.mockImplementation(async () => undefined);

		await login(request, response, next);

		expect(response.statusCode).toBe(401);
	});

	it('return 401 if password does not match', async () => {
		const userName = faker.name.firstName();
		const actualPassword = faker.internet.password();
		const fakePassword = faker.internet.password();
		const user = {
			id: faker.datatype.number(),
			userName,
			password: actualPassword,
			name: faker.name.fullName(),
			contactNumbers: faker.phone.number(),
			email: faker.internet.email(),
		};
		const request = httpMocks.createRequest({
			userName,
			password: fakePassword,
		});
		const response = httpMocks.createResponse();
		const next = jest.fn();

		jest
			.spyOn(userRepository, 'findUserByName')
			.mockImplementation(async () => user);

		jest.spyOn(bcrypt, 'compare').mockImplementation(async () => false);

		await login(request, response, next);

		expect(response.statusCode).toBe(401);
	});

	it('delete a user', async () => {
		const request = httpMocks.createRequest();
		const response = httpMocks.createResponse();
		const next = jest.fn();

		jest
			.spyOn(userRepository, 'deleteUser')
			.mockImplementation(async () => undefined);

		await remove(request, response, next);

		expect(response.statusCode).toBe(204);
	});

	it('load a user information', async () => {
		const user = {
			id: faker.datatype.number(),
			userName: faker.name.fullName(),
			password: faker.internet.password(),
			name: faker.name.fullName(),
			contactNumbers: faker.phone.number(),
			email: faker.internet.email(),
		};
		const numOfReservationsOfUser = faker.datatype.number({ min: 0, max: 30 });
		const numOfInterestedCompaniesOfUser = faker.datatype.number({
			min: 0,
			max: 30,
		});
		const surveyArray = [
			{
				surveyId: faker.datatype.number(),
				bugId: faker.datatype.number(),
				bugName: faker.animal.insect(),
				surveyDate: faker.date.recent(3).toString(),
			},
			{
				surveyId: faker.datatype.number(),
				bugId: faker.datatype.number(),
				bugName: faker.animal.insect(),
				surveyDate: faker.date.recent(3).toString(),
			},
		];
		const productArray = [
			{
				productInterestId: faker.datatype.number(),
				productId: faker.datatype.number(),
				userId: faker.datatype.number(),
				productName: faker.commerce.productName(),
				thumbnail: faker.internet.url(),
				numOfInterestedUsers: faker.datatype.number({ min: 0, max: 30 }),
			},
			{
				productInterestId: faker.datatype.number(),
				productId: faker.datatype.number(),
				userId: faker.datatype.number(),
				productName: faker.commerce.productName(),
				thumbnail: faker.internet.url(),
				numOfInterestedUsers: faker.datatype.number({ min: 0, max: 30 }),
			},
		];
		const reservationArray = [
			{
				reservationId: faker.datatype.number(),
				userId: faker.datatype.number(),
				companyName: faker.company.name(),
				bugName: faker.animal.insect(),
				processState: faker.datatype.number({ min: 0, max: 3 }),
				reservationDateTime: faker.date.recent(3).toString(),
				visitDateTime: faker.date.soon(3).toString(),
			},
			{
				reservationId: faker.datatype.number(),
				userId: faker.datatype.number(),
				companyName: faker.company.name(),
				bugName: faker.animal.insect(),
				processState: faker.datatype.number({ min: 0, max: 3 }),
				reservationDateTime: faker.date.recent(3).toString(),
				visitDateTime: faker.date.soon(3).toString(),
			},
		];

		const request = httpMocks.createRequest();
		const response = httpMocks.createResponse();
		const next = jest.fn();

		jest
			.spyOn(userRepository, 'findUserById')
			.mockImplementation(async () => user);

		jest
			.spyOn(companiesRepository, 'getNumberOfReservationsOfUser')
			.mockImplementation(async () => numOfReservationsOfUser);

		jest
			.spyOn(companiesRepository, 'getNumberOfInterestedCompaniesOfUser')
			.mockImplementation(async () => numOfInterestedCompaniesOfUser);

		jest
			.spyOn(bugsRepository, 'getSurveyItemsOfUser')
			.mockImplementation(async () => surveyArray);

		jest
			.spyOn(productsRepository, 'getProductItemsOfUser')
			.mockImplementation(async () => productArray);

		jest
			.spyOn(companiesRepository, 'getReservationItemsOfUser')
			.mockImplementation(async () => reservationArray);

		await getMyPage(request, response, next);

		const userDetail = {
			...user,
			accumulatedNumOfUsages: numOfReservationsOfUser,
			numberOfInterestedCompanies: numOfInterestedCompaniesOfUser,
			surveyList: surveyArray,
			updatedProductList: productArray,
			reservationList: reservationArray,
		};

		expect(response.statusCode).toBe(200);
		expect(response._getJSONData()).toMatchObject(userDetail);
	});
});

유닛테스트의 대상이 되는 컨트롤러 함수인 createUser, getMyPage, login, remove를 모두 import 해왔다. user.ts에서 내부적으로 의존하고 있는 외부 모듈들 역시 import 하는 동시에 jest.mock(경로명)을 통해 모듈 전체를 mocking 할 거라고 선언해주고 jest.spyOn(레포지토리명, ‘API 명’).mockImplementation(…)을 통해 함수의 구현 사항을 mocking 했다. 그러나 위의 코드들은 모듈 간 의존성이 강하고 테스트 구현이 복잡하다는 단점이 있다. 따라서 인트로에서 언급했듯이 stub의 방식을 통해 리팩토링 해보았다.

/src/controller/user.ts (refactoring version 🪄)

import { Request, Response, NextFunction } from 'express';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { config } from '../config.js';
import { UserRegistration, UserLogin, ProductItem, UserRepository, ProductRepository, BugRepository, CompanyRepository } from '../types/index.js';

export default class UserController {
	userRepository: UserRepository;
	productRepository: ProductRepository;
	bugRepository: BugRepository;
	companyRepository: CompanyRepository;

	constructor(userRepository: UserRepository, productRepository: ProductRepository, bugRepository: BugRepository, companyRepository: CompanyRepository) {
		this.userRepository = userRepository;
		this.productRepository = productRepository;
		this.bugRepository = bugRepository;
		this.companyRepository = companyRepository;
	}

	createUser = async (req: Request, res: Response, next: NextFunction) => {
		const { userName, password } = req.body as UserRegistration;
		const user = await this.userRepository.findUserByName(userName);
		if (user) {
			return res.sendStatus(409);
		}

		const hashed = await bcrypt.hash(
			password,
			parseInt(config.bcrypt.saltsRound)
		);
		const userId = await this.userRepository.createUser({
			...req.body,
			password: hashed,
		});

		const token = this.createJWT(userId);

		res.status(201).json({
			token,
			userName,
		});
	};

	login = async (req: Request, res: Response, next: NextFunction) => {
		const { userName, password } = req.body as UserLogin;
		const user = await this.userRepository.findUserByName(userName);
		if (!user) return res.sendStatus(401);

		const match = await bcrypt.compare(password, user.password);
		if (!match) return res.sendStatus(401);

		const token = this.createJWT(user.id);

		res.status(200).json({
			token,
			userName,
		});
	};

	remove = async (req: Request, res: Response, next: NextFunction) => {
		await this.userRepository.deleteUser(req.userId!);
		res.sendStatus(204);
	};

	getMyPage = async (req: Request, res: Response, next: NextFunction) => {
		const { findUserById } = this.userRepository;
		const {
			getNumberOfReservationsOfUser,
			getNumberOfInterestedCompaniesOfUser,
			getReservationItemsOfUser,
		} = this.companyRepository;
		const { getSurveyItemsOfUser } = this.bugRepository;
		const { getProductItemsOfUser } = this.productRepository;

		const user = await findUserById(req.userId!);
		const accumulatedNumOfUsages = await getNumberOfReservationsOfUser(
			req.userId!
		);
		const numberOfInterestedCompanies =
			await getNumberOfInterestedCompaniesOfUser(req.userId!);
		const surveyList = await getSurveyItemsOfUser(req.userId!);
		const productList = await getProductItemsOfUser(req.userId!);
		const reservationList = await getReservationItemsOfUser(req.userId!);

		let updatedProductList;
		await Promise.all(
			productList.map(async (product) => {
				return this.updateNumOfInterestedUsers(product);
			})
		).then((result) => (updatedProductList = result));

		const userDetail = {
			...user,
			accumulatedNumOfUsages,
			numberOfInterestedCompanies,
			surveyList,
			updatedProductList,
			reservationList,
		};

		res.status(200).json(userDetail);
	};

	private createJWT = (userId: number): string => {
		return jwt.sign({ userId }, config.jwt.privateKey, {
			expiresIn: config.jwt.expirSecs,
		});
	};

	private updateNumOfInterestedUsers = async (
		product: ProductItem
	): Promise<ProductItem> => {
		const numOfUsers =
			await this.productRepository.getNumberOfInterestedUsersOfProduct(
				product.productId.toString()
			);
		product.numOfInterestedUsers = numOfUsers;
		return product;
	};
}

기존에 export했던 함수들을 UserController 클래스 안에 포함시켜 클래스 자체를 default로 export했다. 클래스가 외부로부터 네 개의 레포지토리 객체를 주입받아야 하는데, 각각의 레포지토리 인터페이스를 따로 정의해주어야 한다.

/types/index.d.ts

...
export interface UserRepository {
	createUser(user: UserRegistration): Promise<number>;
	findUserById(userId: number): Promise<User>;
	findUserByName(userName: string): Promise<User>;
	deleteUser(userId: number): Promise<undefined>;
}

export interface ProductRepository {
	getProducts(): Promise<Product[]>;
	getProduct(productId: string): Promise<Product>;
	isProductInterested(userId: number, productId: string): Promise<number>;
	addProductInterest(userId: number, productId: string): Promise<number>;
	removeProductInterest(userId: number, productId: string): Promise<undefined>;
	getProductItemsOfUser(userId: number): Promise<ProductItem[]>;
	getNumberOfInterestedUsersOfProduct(productId: string): Promise<number>;
}

export interface CompanyRepository {
	getCompanies(): Promise<Company[]>;
	reserveCompany(
		userId: number,
		companyId: string,
		reservation: ReservationForm
	): Promise<number>;
	getReservationDetail(reservationId: string): Promise<ReservationDetail>;
	findCompanyById(companyId: string): Promise<Company>;
	isCompanyInterested(userId: number, companyId: string): Promise<number>;
	addCompanyInterest(userId: number, companyId: string): Promise<number>;
	removeCompanyInterest(userId: number, companyId: string): Promise<undefined>;
	getNumberOfReservationsOfUser(userId: number): Promise<number>;
	getNumberOfInterestedCompaniesOfUser(userId: number): Promise<number>;
	getNumberOfInterestedUsersOfCompany(companyId: string): Promise<number>;
	getReservationItemsOfUser(userId: number): Promise<ReservationItem[]>;
}

export interface BugRepository {
	getBugs(): Promise<Bug[]>;
	getBug(bugId: string): Promise<Bug>;
	addSurveyResult(userId: number, bugId: string): Promise<number>;
	getSurveyItemsOfUser(userId: number): Promise<SurveyItem[]>;
}

타입스크립트에서 지원하는 type가 여러 타입의 데이터를 하나의 타입으로 정의할 때 주로 사용된다면 interface는 규격을 정의하고 이에 대해 구현을 해야할 때 사용된다. 편의상 /types/index.d.ts에 프로젝트에서 사용한 커스텀 타입과 인터페이스를 모두 한번에 정의해두었다.

/src/controller/test/user.test.ts (refactoring version 🪄)

import httpMocks from 'node-mocks-http';
import { faker } from '@faker-js/faker';
import * as bcrypt from 'bcrypt';
import UserController from '../user';
import UserRepositoryImpl from './stub_user_repository_impl';
import BugRepositoryImpl from './stub_bug_repository_impl';
import CompanyRepositoryImpl from './stub_company_repository_impl';
import ProductRepositoryImpl from './stub_product_repository_impl';

jest.mock('bcrypt');

describe('User Controller', () => {
	let userRepository: UserRepositoryImpl;
	let productRepository: ProductRepositoryImpl;
	let companyRepository: CompanyRepositoryImpl;
	let bugRepository: BugRepositoryImpl;
	let userController: UserController;

	beforeEach(() => {
		userRepository = new UserRepositoryImpl();
		productRepository = new ProductRepositoryImpl();
		companyRepository = new CompanyRepositoryImpl();
		bugRepository = new BugRepositoryImpl();
		userController = new UserController(
			userRepository,
			productRepository,
			bugRepository,
			companyRepository
		);
	});

	it('create user', async () => {
		const registrationForm = userRepository.registrationForm;
		const request = httpMocks.createRequest({
			body: registrationForm,
		});
		const response = httpMocks.createResponse();
		const next = jest.fn();
		userRepository.findUserByName = jest
			.fn()
			.mockImplementation(() => undefined);

		await userController.createUser(request, response, next);

		expect(response.statusCode).toBe(201);
		expect(response._getJSONData()).toMatchObject({
			userName: request.body.userName,
		});
		expect(response._getJSONData().token).toBeDefined();
	});

	it('return 409 if user already exists', async () => {
		const registrationForm = userRepository.registrationForm;

		const request = httpMocks.createRequest({
			body: registrationForm,
		});
		const response = httpMocks.createResponse();
		const next = jest.fn();

		await userController.createUser(request, response, next);

		expect(response.statusCode).toBe(409);
	});

	it('succeed to login', async () => {
		const loginInfo = userRepository.loginInfo;

		const request = httpMocks.createRequest({
			body: loginInfo,
		});
		const response = httpMocks.createResponse();
		const next = jest.fn();

		jest.spyOn(bcrypt, 'compare').mockImplementation(async () => true);

		await userController.login(request, response, next);

		expect(response.statusCode).toBe(200);
		expect(response._getJSONData()).toMatchObject({
			userName: request.body.userName,
		});
		expect(response._getJSONData().token).toBeDefined();
	});

	it('return 401 if user does not exist', async () => {
		const loginInfo = userRepository.loginInfo;
		const request = httpMocks.createRequest({
			body: loginInfo,
		});
		const response = httpMocks.createResponse();
		const next = jest.fn();
		userRepository.findUserByName = jest
			.fn()
			.mockImplementation(() => undefined);

		await userController.login(request, response, next);

		expect(response.statusCode).toBe(401);
	});

	it('return 401 if password does not match', async () => {
		const loginInfo = userRepository.loginInfo;
		const request = httpMocks.createRequest({ body: loginInfo });
		const response = httpMocks.createResponse();
		const next = jest.fn();

		jest.spyOn(bcrypt, 'compare').mockImplementation(async () => false);

		await userController.login(request, response, next);

		expect(response.statusCode).toBe(401);
	});

	it('delete a user', async () => {
		const request = httpMocks.createRequest();
		const response = httpMocks.createResponse();
		const next = jest.fn();

		await userController.remove(request, response, next);

		expect(response.statusCode).toBe(204);
	});

	it('load a user information', async () => {
		const user = userRepository.fakeUser;
		const numOfReservationsOfUser = faker.datatype.number({ min: 0, max: 30 });
		const numOfInterestedCompaniesOfUser = faker.datatype.number({
			min: 0,
			max: 30,
		});
		const surveyArray = bugRepository.surveyItemArray;
		const productArray = productRepository.productItemArray;
		const reservationArray = companyRepository.reservationItemArray;

		const request = httpMocks.createRequest();
		const response = httpMocks.createResponse();
		const next = jest.fn();

		companyRepository.getNumberOfReservationsOfUser = jest
			.fn()
			.mockImplementation(() => numOfReservationsOfUser);
		companyRepository.getNumberOfInterestedCompaniesOfUser = jest
			.fn()
			.mockImplementation(() => numOfInterestedCompaniesOfUser);

		await userController.getMyPage(request, response, next);

		const userDetail = {
			...user,
			accumulatedNumOfUsages: numOfReservationsOfUser,
			numberOfInterestedCompanies: numOfInterestedCompaniesOfUser,
			surveyList: surveyArray,
			updatedProductList: productArray,
			reservationList: reservationArray,
		};

		expect(response.statusCode).toBe(200);
		expect(response._getJSONData()).toMatchObject(userDetail);
	});
});

이제는 모듈 전체를 mocking 하지 않고 같은 test 폴더 내에 stub 용으로 만들어둔 RepositoryImpl 객체를 import 해오고 필요한 부분만 mocking 할 수 있도록 해주어 코드의 양이 훨씬 줄어들었다. 각 단위 테스트 이전마다 객체들을 새로 생성해주는 이유는 각 테스트별로 독립성을 유지하기 위해서이다. 독립성이 유지된다면 이전 테스트에서 코드의 일부를 mocking 했을 지라도 다음 테스트에서는 Stub class의 구현사항이 그대로 적용될 수 있다.

Integration test

통합 테스는 서버를 실제로 구동시켜서 axios를 이용해 서버에 요청을 하고 응답을 받아옴으로써 이전에 검증된 단위들의 상호작용을 테스트하는 것이다. 서버를 수동적으로 시작 및 종료시키기 위한 작업, 각각의 통합 테스트 시 구동되는 서버의 포트가 충돌하지 않도록 해주는 작업, 그리고 모든 통합 테스트를 마친 뒤 db의 users 테이블을 정리해주는 작업을 별도로 해주었다. 단순성을 위해 User API와 관련해서는 회원가입과 로그인, Products API와 관련해서는 상품 목록과 상품 아이템 불러오기 기능만 통합 테스트하였다. Companies API, Bugs API는 따로 테스트하지 않았다. 따라서 새로운 사용자 계정을 만들 때마다 데이터베이스에 테스트용 유저 정보가 불필요하게 삽입되므로 global teardown 작업 시 유저 테이블의 row 들을 모두 삭제하게끔 처리해주었다.

/src/app.ts

import express from 'express';
import { Request, Response, NextFunction } from 'express';
import yamljs from 'yamljs';
import morgan from 'morgan';
import swaggerUi from 'swagger-ui-express';
import { Server } from 'http';
import BugsRouter from './routes/bugs.js';
import UserRouter from './routes/user.js';
import CompaniesRouter from './routes/companies.js';
import ProductsRouter from './routes/products.js';
import { pool } from './db/database.js';

const app = express();

const apiJSDocument = yamljs.load('./api/openapi.yaml');

export const startServer = (port: number) => {
	app.use(express.json());
	app.use(morgan('tiny'));
	app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(apiJSDocument));

	app.use('/user', UserRouter);
	app.use('/bugs', BugsRouter);
	app.use('/companies', CompaniesRouter);
	app.use('/products', ProductsRouter);

	app.use('/', (req: Request, res: Response, next: NextFunction) => {
		res.sendStatus(404);
	});

	app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
		if (err) {
			console.log(err);
			res.status(500).send('Internal Server Error...');
		}
	});

	let server: Server;
	server = app.listen(port);

	console.log('Server started...');
	return server;
};

export const stopServer = async (server: Server) => {
	return new Promise(async (resolve, reject) => {
		server.close((error) => {
			if (error) reject(error);
			try {
				pool.end();
			} catch (error) {
				reject(error);
			}
			resolve(null);
		});
	});
};

/src/index.ts

import { startServer } from './app.js';
import { config } from './config.js';

try {
	startServer(config.host.port);
} catch (error) {
	console.log('Cannot start server...');
}

startServer 함수는 인자로 받은 특정 포트에서 구동되는 서버 객체를 반환하게 해주었고 stopServer는 server와의 연결을 비동기적으로 끊고 이 과정에서 에러가 발생하지 않는다면 데이터베이스 pool과의 연결도 끊게 해주었다. 정의된 두 함수를 이용하여 production 모드에서는 메인 entry를 index.js로 해주어 파일 내부에서 startServer 호츨을 통해 서버를 수동적으로 시작하게 해주었다.

/src/tests/user.test.ts

import axios, { AxiosInstance } from 'axios';
import { faker } from '@faker-js/faker';
import { startServer, stopServer } from '../app.js';
import { Server } from 'http';
import { AddressInfo } from 'net';
import { createNewAccount, createUserDetails } from './auth_utils.js';

describe('User APIs', () => {
	let server: Server;
	let request: AxiosInstance;

	beforeAll(() => {
		server = startServer(0);
		const address = server.address() as AddressInfo;
		request = axios.create({
			baseURL: `http://localhost:${address.port}`,
			validateStatus: null,
		});
	});

	afterAll(async () => {
		await stopServer(server);
	});

	it('POST /signup', async () => {
		const registrationForm = createUserDetails();
		const response = await request.post('/user/signup', registrationForm);

		expect(response.status).toBe(201);
		expect(response.data.token).toBeDefined();
		expect(response.data.userName).toEqual(registrationForm.userName);
	});

	it('POST /login', async () => {
		const user = await createNewAccount(request);
		const loginInFo = { userName: user.userName, password: user.password };
		const response = await request.post('/user/login', loginInFo);

		expect(response.status).toBe(200);
		expect(response.data.token).toBeDefined();
		expect(response.data.userName).toEqual(loginInFo.userName);
	});

	it('return 401 if user is not registered', async () => {
		const randomUserName = faker.name.firstName();
		const randomPassword = faker.internet.password();
		const loginInfo = { userName: randomUserName, password: randomPassword };
		const response = await request.post('/user/login', loginInfo);

		expect(response.status).toBe(401);
	})

	it('return 401 if password does not match', async() => {
		const user = await createNewAccount(request);
		const fakePassword= user.password.toUpperCase();
		const loginInfo = { userName: user.userName, password: fakePassword };
		const response = await request.post('/user/login', loginInfo);

		expect(response.status).toBe(401);
	})
});

/src/tests/auth_utils.ts

import { faker } from '@faker-js/faker';
import { AxiosInstance } from 'axios';

export async function createNewAccount(request: AxiosInstance) {
	const userDetails = createUserDetails();
	const response = await request.post('/user/signup', userDetails);

	return {
		...userDetails,
		token: response.data.token,
	};
}

export function createUserDetails() {
	return {
		userName: faker.name.firstName(),
		password: faker.internet.password(),
		name: faker.name.middleName(),
		contactNumbers: faker.phone.number(),
		email: faker.internet.email(),
	};
}

test 모드에서는 beforeAll에서 startServer 호출을 통해 서버를 수동적으로 시작하게 해준 다음 afterAll에서 stopServer 호출을 통해 서버와의 연결을 끊게 해주었다. 이때 테스트 모드에서 startServer의 인자로 포트 번호 0을 넘겨주면 운영 체제가 알아서 현재 사용되고 있지 않은 포트를 할당하게 해주기 때문에 테스트를 병렬적으로 실행시킬 때 충돌이 발생하지 않는다. 그리고 반환된 server의 address에 접근해 port 번호를 알아오면 base url과 함께 axios의 인스턴스를 생성할 수 있다. 이 인스턴스는 이후 POST, GET 요청을 해서 응답을 반환할 수 있다. Products API들을 테스트할 때 요청 시 token을 헤더에 포함시켜 보내야 하므로 이전에 회원가입을 통해 받아온 token 정보를 받아 올 필요가 있기 때문에 createNewAccount() 함수를 두 기능의 테스트에서 모두 사용할 수 있도록 유틸리티 함수로 따로 빼두었다. 회원가입 정보를 생성하는 createUserDetails() 역시 테스트시 필요로 하므로 export 되도록 하였다.

/src/tests/products.test.ts

import axios, { AxiosInstance } from 'axios';
import { Server } from 'http';
import { AddressInfo } from 'net';
import { faker } from '@faker-js/faker';
import { createNewAccount } from './auth_utils';
import { startServer, stopServer } from '../app';

describe('Products APIs', () => {
	let server: Server;
	let request: AxiosInstance;

	beforeAll(() => {
		server = startServer(0);
		const address = server.address() as AddressInfo;
		request = axios.create({
			baseURL: `http://localhost:${address.port}`,
			validateStatus: null,
		});
	});

	afterAll(async () => {
		await stopServer(server);
	});

	it('GET /products', async () => {
		const user = await createNewAccount(request);
		const response = await request.get('/products', {
			headers: {
				Authorization: `Bearer ${user.token}`,
			},
		});

		expect(response.status).toBe(200);
		expect(response.data.length).toBeGreaterThan(0);
	});

	it('GET /products/:product_id', async () => {
		const user = await createNewAccount(request);
        const productId = 1;
		const response = await request.get(`/products/${productId}`, {
			headers: {
				Authorization: `Bearer ${user.token}`,
			},
		});

		expect(response.status).toBe(200);
		expect(response.data).toBeDefined();
	});

	it('return 404 with invalid product id', async () => {
		const user = await createNewAccount(request);
        const fakeProductId = faker.datatype.number({ min: 10000 });
		const response = await request.get(`/products/${fakeProductId}`, {
			headers: {
				Authorization: `Bearer ${user.token}`,
			},
		});

		expect(response.status).toBe(404);
	});
});

상품 목록 혹은 아이템을 불러오는 GET 요청을 할 때 두번째 인자에 회원가입을 해서 받아온 토큰을 헤더에 포함시키는 작업을 처리해주었다.

src/tests/global_teardown.ts

import mysql from 'mysql';
import dotenv from 'dotenv';
import path from 'path';

dotenv.config({ path: path.resolve(__dirname, '../../.env.test') });

export default async function teardown() {
	return new Promise((resolve, reject) => {
		const pool = mysql.createPool({
			host: process.env['DB_HOST'],
			user: process.env['DB_USER'],
			password: process.env['DB_PASSWORD'],
			database: process.env['DB_DATABASE'],
		});

		pool.query('DELETE FROM users', (error) => {
			if (error) {
				console.log(error);
				reject(error);
			}
			console.log('DB is cleared');
			resolve(null);
		});
	});
}

global_teardown.ts 파일에서는 반드시 하나의 함수를 default로 export하고 db에 접근하는 코드가 비동기적으로 실행될 수 있도록 Promise 내부에 구현해주었다. 전역으로 인식되는 파일의 경우 어플리케이션에서 사용하는 모듈을 import 해올 수 없으므로 데이터베이스 pool을 다시 생성해주어야 한다. 그리고 package.json의 scripts 명령어에 통합 테스트 혹은 전체 테스트를 실행하고 난 뒤 tear down 작업이 될 수 있도록 명령어를 추가해주기만 하면 된다. 사실 이러한 테스트를 할 때마다 기존의 유효한 User 테이블의 회원 정보들이 모두 사라진다는 점에서 이는 좋은 코드라고 할 수 없다. 이런 식으로 global teardown 작업을 수동적으로 해주는 것 말고 도커를 사용하면 개별 환경에서 db와 관련된 작업을 테스트할 수 있다고 한다. 다음 프로젝트에서는 꼭 도커를 적용할 수 있도록 더 공부해야겠다.