Jest - TDD를 Jest로 직접 해보자 (5)
서론
오늘은 날씨 관련된 기능 하나를 TDD 방식으로 만들어보자.
1단계 : 기능의 요구사항 및 스팩
날씨 정보를 받아오는 가장 기본적인 기능은 지역정보를 객체로 받고 그 요청에 맞는 정보를 찾아 응답하는 것이다.
이를 수행하기위한 서비스레이어의 비지니스 로직이 가져야할 요구사항 및 스팩은 아래와 같다.
-
클라이언트로 부터 date(날짜), time(시간), area(지역코드) 를 객체로 받고 데이터베이스에서 해당 데이터들을 찾아 클라이언트에게 응답하는 'getWeatherInfo' 메소드를 구현해야한다.
-
클라이언트로부터 받는 데이터의 형태는 {date : '20230302' , time : 15 , area : 11} 과 같은 형태이다.
-
클라이언트로부터 받은 객체데이터에 맞는 데이터를 데이터베이스에서 찾아 반환해야한다.
-
반환하는 데이터형태는 [temperature:number, precipitation:number, precipitationPattern:number, windSpeed:number, windDirection:number, humidity:number] 와 같아야한다.
2단계 : 테스트 코드 작성
위의 요구사항을 충족하는 테스트 코드를 만들기 위해서는 우선 새로운 엔티티를 모듈에 추가해야한다.
typescript1import { Test, TestingModule } from '@nestjs/testing'; 2import { WeatherService } from './weather.service'; 3import { Repository, SelectQueryBuilder } from 'typeorm'; 4import { localEntity } from '../entities/local.entity'; 5import { getRepositoryToken } from '@nestjs/typeorm'; 6import { weatherEntity } from 'src/entities/weather.entity'; 7import { SelectWeatherDto } from './dto/weather.dto'; 8 9describe('WeatherService', () => { 10 let service: WeatherService; 11 let localRepository: Repository<localEntity>; 12 let weatherRepository: Repository<weatherEntity>; 13 14 beforeEach(async () => { 15 const module: TestingModule = await Test.createTestingModule({ 16 providers: [ 17 WeatherService, 18 { 19 provide: getRepositoryToken(localEntity), 20 useValue: { 21 find: jest.fn(), 22 createQueryBuilder: jest.fn(), 23 }, 24 }, 25 { 26 provide: getRepositoryToken(weatherEntity), 27 useValue: { find: jest.fn() }, 28 }, 29 ], 30 }).compile(); 31 32 service = module.get<WeatherService>(WeatherService); 33 localRepository = module.get<Repository<localEntity>>( 34 getRepositoryToken(localEntity), 35 ); 36 weatherRepository = module.get<Repository<weatherEntity>>( 37 getRepositoryToken(weatherEntity), 38 ); 39 }); 40...
위의 코드에서는 기존의 서비스 레이어를 테스트하는 모듈에 weatherEntity 를 사용하기위해 weatherRepository를 추가했다.
또한 이번 코드에서 사용할 것으로 예상되는 typeOrm 의 메소드인 find 를 mock 했다.
이제 테스트할 코드를 만들어보자.
테스트 코드에 필요한 요소는 아래와 같다.
-
클라이언트로 부터 받은 데이터.
-
'getWeatherInfo' 가 반환할 데이터.
-
typeOrm 의 메소드 Mocking
위의 요소를 생각하며 테스트 코드를 작성해보자.
typescript1 describe('getWeatherInfo', () => { 2 it('should return weather information', async () => { 3 const clientData: SelectWeatherDto = { 4 area: 11, 5 date: '20230302', 6 time: 15, 7 }; 8 const returnWeatherInfo: weatherEntity[] = [ 9 { 10 id: 1, 11 area: clientData.area, 12 time: clientData.time, 13 date: clientData.date, 14 temperature: 12, 15 precipitation: 0, 16 precipitationPattern: 0, 17 windSpeed: 0.2, 18 windDirection: 340, 19 humidity: 40, 20 }, 21 ]; 22 jest 23 .spyOn(weatherRepository, 'find') 24 .mockResolvedValue(returnWeatherInfo); 25 26 const weatherInfo = await service.getWeatherInfo(clientData); 27 28 expect(weatherInfo).toEqual(returnWeatherInfo); 29 expect(weatherRepository.find).toHaveBeenCalled(); 30 }); 31 });
위의 코드는 clinetData를 매개변수로 받는 'getWeatherInfo'를 구현한다.
반환 값으로는 returnWeatherInfo로 하고 그 값이 반환값과 같은지 테스트한다.
또한 weatherRository의 find 메소드가 호출되었는지 테스트한다.
이제 데이터베이스가 실패했을때를 테스트 해보자.
typescript1 it('should throw error when database error', async () => { 2 const clientData: SelectWeatherDto = { 3 area: 11, 4 date: '20230302', 5 time: 15, 6 }; 7 8 jest.spyOn(weatherRepository, 'find').mockRejectedValue(new Error('Database error')), 9 10 await expect(service.getWeatherInfo(clientData)).rejects.toThrow( 11 new Error('Database error'), 12 ); 13 });
위의 코드는 weatherRepository의 find 메소드가 호출되면 'Database error' 라는 예외가 발생하도록 mock 한다.
그리고 해당 에러가 발생했을때 에러메세지가 'Database error' 인지 테스트하는 코드이다.
전체적인 테스트 코드는 아래와 같다.
typescript1describe('getWeatherInfo', () => { 2 it('should return weather information', async () => { 3 const clientData: SelectWeatherDto = { 4 area: 11, 5 date: '20230302', 6 time: 15, 7 }; 8 const returnWeatherInfo: weatherEntity[] = [ 9 { 10 id: 1, 11 area: clientData.area, 12 time: clientData.time, 13 date: clientData.date, 14 temperature: 12, 15 precipitation: 0, 16 precipitationPattern: 0, 17 windSpeed: 0.2, 18 windDirection: 340, 19 humidity: 40, 20 }, 21 ]; 22 jest 23 .spyOn(weatherRepository, 'find') 24 .mockResolvedValue(returnWeatherInfo); 25 26 const weatherInfo = await service.getWeatherInfo(clientData); 27 28 expect(weatherInfo).toEqual(returnWeatherInfo); 29 expect(weatherRepository.find).toHaveBeenCalled(); 30 }); 31 it('should throw error when database error', async () => { 32 const clientData: SelectWeatherDto = { 33 area: 11, 34 date: '20230302', 35 time: 15, 36 }; 37 38 jest.spyOn(weatherRepository, 'find').mockRejectedValue(new Error('Database error')), 39 40 await expect(service.getWeatherInfo(clientData)).rejects.toThrow( 41 new Error('Database error'), 42 ); 43 }); 44 });
3단계 : 테스트 실패 확인
당연히 실제 코드가 없으니 테스트는 실패한다.
4단계 : 코드 작성
위의 테스트 코드를 성공시키기 위한 조건은 아래와 같다.
-
매개변수로 SelectWeatherDto 형태의 selectWeatherDto 를 받는다.
-
반환값은 weatherEntity[] 이다.
-
예외가 발생했을때 오류를 throw 해야한다.
typescript1 async getWeatherInfo( 2 selectWeatherDto: SelectWeatherDto, 3 ): Promise<weatherEntity[]> { 4 try { 5 return this.weatherRepository.find(selectWeatherDto); 6 } catch (err) { 7 throw err; 8 } 9 }
5단계 : 테스트 통과 확인
image
테스트를 통과했다.
6단계 : 리팩토링
생각해보니 해당 요청에 응답하는 데이터는 한개일 수밖에 없다.
그 이유는 weatherEntity의 구조를 보면 알 수 있다.
typescript1import { Column, Entity, PrimaryGeneratedColumn, Unique } from 'typeorm/index'; 2 3@Unique(['date', 'time', 'area']) 4@Entity('weather_info') 5export class weatherEntity { 6 @PrimaryGeneratedColumn('increment', { type: 'bigint' }) 7 id: number; 8 9 @Column({ type: 'int' }) 10 area: number; 11 12 @Column({ length: 10 }) 13 date: string; 14 15 @Column({ type: 'int' }) 16 time: number; 17 18 @Column({ type: 'int' }) 19 temperature: number; 20 21 @Column({ type: 'int' }) 22 precipitation: number; 23 24 @Column({ type: 'int' }) 25 precipitationPattern: number; 26 27 @Column({ type: 'double' }) 28 windSpeed: number; 29 30 @Column({ type: 'double' }) 31 windDirection: number; 32 33 @Column({ type: 'double' }) 34 humidity: number; 35}
date, time, area 는 유니크 키를 가지기때문에 해당 세가지 데이터를 이용해 데이터를 찾는 방식에서는 하나의 데이터밖에 나올 수 없기때문이다.
그렇기때문에 'getWeatherInfo' 는 weatherEntity[] 와 같은 객체배열의 형태가 아닌 weatherEntity 와 같은 객체의 형태가 더 옳다고 생각했다.
해당 부분을 리팩토링해보면 아래와 같다.
typescript1 it('should return weather information', async () => { 2 const clientData: SelectWeatherDto = { 3 area: 11, 4 date: '20230302', 5 time: 15, 6 }; 7 const returnWeatherInfo: weatherEntity = 8 { 9 id: 1, 10 area: clientData.area, 11 time: clientData.time, 12 date: clientData.date, 13 temperature: 12, 14 precipitation: 0, 15 precipitationPattern: 0, 16 windSpeed: 0.2, 17 windDirection: 340, 18 humidity: 40, 19 }; 20 jest 21 .spyOn(weatherRepository, 'findOne') 22 .mockResolvedValue(returnWeatherInfo); 23 24 const weatherInfo = await service.getWeatherInfo(clientData); 25 26 expect(weatherInfo).toEqual(returnWeatherInfo); 27 expect(weatherRepository.findOne).toHaveBeenCalled(); 28 });
위의 테스트코드에서 반환하는 예시데이터를 객체 배열에서 객체로 변경하였다.
또한 객체배열을 반환하는 typeOrm의 메소드 fine 대신 객체를 반환하는 findOne 으로 변경하여 Mock 하였다.
해당 기능을 테스트하기 위해 모듈부분도 조금 손봐주어야한다.
typescript1 2 beforeEach(async () => { 3 const module: TestingModule = await Test.createTestingModule({ 4 providers: [ 5 WeatherService, 6 { 7 provide: getRepositoryToken(localEntity), 8 useValue: { 9 find: jest.fn(), 10 createQueryBuilder: jest.fn(), 11 }, 12 }, 13 { 14 provide: getRepositoryToken(weatherEntity), 15 useValue: { findOne: jest.fn() }, 16 }, 17 ], 18 }).compile(); 19 20 service = module.get<WeatherService>(WeatherService); 21 localRepository = module.get<Repository<localEntity>>( 22 getRepositoryToken(localEntity), 23 ); 24 weatherRepository = module.get<Repository<weatherEntity>>( 25 getRepositoryToken(weatherEntity), 26 ); 27 });
providers 부분의 weatherEntity 를 Repository로 할당해주는 코드중 find를 findOne으로 수정하였다.
이제 실제 코드를 수정하자.
typescript1 async getWeatherInfo( 2 selectWeatherDto: SelectWeatherDto, 3 ): Promise<weatherEntity> { 4 try { 5 return this.weatherRepository.findOne(selectWeatherDto); 6 } catch (err) { 7 throw err; 8 } 9 }
실제코드에서 Promise 형태의 weatherEntity[] 객체 배열을 반환하는 것에서 weatherEntity 객체를 반환하도록 수정하였다.
7단계 : 2~6단계 반복
기능의 요구사항 및 스팩을 모두 만족했으니 7단계는 스킵한다.