테스트 코드
테스트 코드
이 글은 jest를 활용한 TDD 하는 방법에 대한 내용은 아니다. layer가 나눠져 있는 환경에서의 테스트 코드 작성, 의존성을 활용해 mock 함수 제거에 대한 개인적인 고민이 담긴 글이다. 그렇기 때문에 해당 글이 정답 일리는 없고, 그냥 테스트 코드에 매우 미숙한 1년 차 주니어 개발자의 개인 공부 기록이다.
최근 테스트 코드를 제대로 작성해보고 싶어서 작년에 샀던 강의를 다시 들었다. 해당 강의는 간단한 CRUD 기능을 TDD로 개발하는 강의였다. 강의에선 javascript와 express, jest를 활용해 테스트코드를 작성했지만, 직접 해볼 땐 Typescript와 express, jest를 활용해 진행했다.
먼저 아래는 모든 비지니스 로직이 한 곳에 모여있는 코드이다.
1-1. 모든 비지니스 로직이 한 곳에 모여있는 코드
// /controller/products.ts
import { Product } from '../models/Product';
import { NextFunction, Request, Response } from 'express';
export class ProductController {
constructor() {}
async createProduct(req: Request, res: Response, next: NextFunction) {
try {
const result = await Product.create(req.body);
res.status(201).json(result);
} catch (err) {
next(err);
}
}
}
1-2. 테스트 코드
// /test/product.test.ts
// 1. 해야 할 일 먼저 생각
// 2. 단위 테스트 작성
// 3. 테스트에 대하응하는 실제 코드 작성
import { ProductController } from '../../controller/products';
import { Product } from '../../models/Product';
import httpMocks from 'node-mocks-http';
import addProduct from '../data/add-product.json';
import { NextFunction, Request, Response } from 'express';
Product.create = jest.fn(); // mock 함수로 만들어줌.
const productController = new ProductController();
let req: Request, res: Response, next: NextFunction;
// 모든 테스트코드들이 실행되기 전엔 먼저 req, res, next 를 mock 함수로 할당하고 실행됨.
beforeEach(() => {
// node-mocks-http 추가
req = httpMocks.createRequest();
res = httpMocks.createResponse();
next = jest.fn();
});
describe('Product Controller Create', () => {
// 해당 describe 안에 있는 테스트코드들이 실행되기 전에 먼저 req.body 에 newProduct 를 할당하고 실행됨
// req.body 값 임의로 추가
beforeEach(() => {
req.body = addProduct;
});
// 1. 함수를 실행하면 201 status code 가 리턴되는지에 대한 테스트 코드 작성
// 함수가 async/await 사용하고 있으면 테스트코드에서도 작성해줘야함
it('should return 201 response status code', async () => {
await productController.createProduct(req, res, next);
expect(res.statusCode).toBe(201);
});
// 2. 함수를 실행하면 결과값이 리턴되는지에 대한 테스트 코드 작성
it('should return json body in response ', async () => {
// @ts-ignore
Product.create.mockReturnValue(addProduct);
await productController.createProduct(req, res, next);
// @ts-ignore
expect(res._getJSONData()).toStrictEqual(addProduct);
});
// 3. 에러 발생 시 어떤 메세지가 리턴되는지에 대한 테스트 코드 작성
it('should handle errors', async () => {
const errorMessage = { message: 'description property missing' };
const rejectedPromise = Promise.reject(errorMessage);
// @ts-ignore
Product.create.mockReturnValue(rejectedPromise);
await productController.createProduct(req, res, next);
expect(next).toBeCalledWith(errorMessage);
});
});
먼저 테스트 코드를 살펴보면 ProductController를 불러와 인스턴스화 한 후 테스트를 진행하고 있다. ProductController는 데이터베이스에 강하게 의존하고 있기 때문에 기능의 수정이나 추가가 매우 어렵고, 예상하지 못한 사이드 이펙트가 많이 발생할 수 있다. 또한 테스트 작성 시 항상 데이터베이스를 거쳐야 하기 때문에 이를 회피하기 위해서 mock 함수를 많이 사용하게 된다.
위의 코드를 작성하면서 외부 의존성과 관계없는 테스트 코드 작성에 대해 고민했다. 이에 얽혀있는 코드를 계층을 나누고 의존성을 멀리 떨어뜨려 놓게 구현해서 계층별로 테스트 코드를 구현했다. 이때 의존성 주입을 통해 약하게 결합되기 때문에 테스트 코드에서도 임의의 객체를 만들어 쉽게 의존성을 주입했다.
먼저 계층화와 의존성 주입을 구현했다.
2. 계층화와 의존성 주입 구현
- /products/products.controller.ts
// /products/products.controller.ts
import { Service } from 'typedi';
import { Product } from '../models/Product';
import { NextFunction, Request, Response } from 'express';
import { createProductInputDto } from '../DTO/createProduct.input.dto';
import ProductsService from './products.service';
@Service()
class ProductsController {
constructor(private productsService: ProductsService) {}
async createProduct(req: Request, res: Response, next: NextFunction) {
try {
const createProductInputData: createProductInputDto = req.body;
const result = await this.productsService.createProduct(createProductInputData);
res.status(201).json(result);
} catch (err) {
next(err);
}
}
}
export default ProductsController;
- /products/products.service.ts
// /products/products.service.ts
import { createProductInputDto } from '../DTO/createProduct.input.dto';
import ProductsRepository from './products.repository';
import { Service } from 'typedi';
@Service()
class ProductsService {
constructor(private productsRepository: ProductsRepository) {}
async createProduct(createProductInputData: createProductInputDto) {
const result = await this.productsRepository.createProduct(createProductInputData);
return result;
}
}
export default ProductsService;
- /products/products.repository.ts
생략함
이렇게 계층화와 의존성 주입을 구현했고, 이 후 계층별로 테스트 코드를 작성했다.
먼저 Service 계층의 테스트 코드이다.
3. Service 계층 테스트 코드
// /test/products.service.test.ts
import ProductsService from '../../products/products.service';
describe('products Service layer unit test', () => {
const addProduct = {
name: 'kim',
description: 'good',
price: 15,
};
const productsRepository = {
createProduct: async () => {
return addProduct;
},
};
// @ts-ignore
const productsService = new ProductsService(productsRepository);
it('createProduct', async () => {
const result = await productsService.createProduct(addProduct);
expect(result).toBe(addProduct);
});
});
기존에 사용하던 mock 함수 대신 productsRepository라는 객체를 임의로 만들고 createProduct 함수를 작성했다. 그리고 new ProductsService(productsRepository)의 인자로 넣어줘 테스트용 repository 의존성을 주입해 테스트 코드를 작성했다.
이렇게 하게 되면 Service 계층은 mock 함수나 데이터베이스에 독립적으로 테스트 코드 작성이 가능해진다. 하지만 Controller 계층의 경우 클라이언트와의 의존성이 몰려있기 때문에 어쩔 수 없이 mock 함수를 활용할 수밖에 없었다.
4. Controller 계층 테스트 코드
import ProductsController from '../../products/products.controller';
import httpMocks from 'node-mocks-http';
import { NextFunction, Request, Response } from 'express';
import { createProductInputDto } from '../../DTO/createProduct.input.dto';
let req: Request, res: Response, next: NextFunction;
beforeEach(() => {
// node-mocks-http 추가
req = httpMocks.createRequest();
res = httpMocks.createResponse();
next = jest.fn();
});
describe('ProductsController', () => {
const addProduct = {
name: 'kim',
description: 'good',
price: 15,
};
const productsService = {
createProduct: async (createProductInputData: createProductInputDto) => {
return addProduct;
},
};
// @ts-ignore
const productsController = new ProductsController(productsService);
it('createProduct', async () => {
await productsController.createProduct(req, res, next);
expect(res.statusCode).toBe(201);
// @ts-ignore
expect(res._getJSONData()).toStrictEqual(addProduct);
});
it('createProduct error', async () => {
productsService.createProduct = jest.fn();
const errorMessage = { message: 'description property missing' };
const rejectedPromise = Promise.reject(errorMessage);
// @ts-ignore
productsService.createProduct.mockReturnValue(rejectedPromise);
await productsController.createProduct(req, res, next);
expect(next).toBeCalledWith(errorMessage);
});
});
(req, res, next) 부분과 next(err)를 통한 에러 핸들링 부분도 테스트 코드 작성은 어쩔 수 없이 mock 함수를 사용했다.
5. 결론
사실 아직 테스트 코드를 실제 비지니스 로직에 대입해 제대로 짜 본 적이 없다. 하지만 아키텍처 설계가 테스트에도 많은 영향을 끼친다는 것을 알 수 있었다. 앞으로 테스트 코드를 꾸준히 구현해보면서 다양한 아키텍처들도 적용해 봐야겠다.
그리고 테스트 코드는 기능 구현의 하위에 있는 선택적인 부분이라는 생각을 많이 했었는데, 앞으로 꾸준히 테스트 코드를 작성해보면서 점점 기능 구현만큼 중요한 것이라는 걸 느끼고 싶다.
'테스트 코드' 카테고리의 다른 글
테스트하기 쉬운 코드, 구조 (2) : mocking 없이 테스트 코드 작성하기 (0) | 2023.01.27 |
---|---|
테스트하기 쉬운 코드, 구조 (1) : mocking 사용한 테스트코드 작성 (0) | 2023.01.26 |