첫번째로는 거래소 연동 (Exchange) 모듈을 개발해보겠습니다.
거래소 연동 (Exchange) 모듈
- 목적: 거래소와의 모든 통신을 담당
- 경로: src/exchange
- 주요 기능:
1. 실시간 시세 조회
2. 잔고 조회
3. 주문 실행 (매수/매도)- 확장성:
1. 인터페이스를 통해 새로운 거래소 쉽게 추가 가능
2. 현재 업비트 구현, 향후 바이낸스 추가 예정
TDD의 핵심은 "Red-Green-Refactor" 사이클입니다.
- Red: 실패하는 테스트 작성
- Green: 테스트를 통과하는 최소한의 코드 작성
- Refactor: 코드 개선
먼저 가장 기본적인 테스트부터 시작해보겠습니다:
// src/exchange/upbit/upbit.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { UpbitService } from './upbit.service';
describe('UpbitService', () => {
let service: UpbitService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UpbitService,
{
provide: ConfigService,
useValue: {
get: jest.fn(),
},
},
],
}).compile();
service = module.get<UpbitService>(UpbitService);
});
it('should have getCurrentPrice method', () => {
expect(service.getCurrentPrice).toBeDefined();
});
});
현재는 UpbitService에 getCurrentPrice가 구현이 되어있지 않기에, 아래와 같이 테스트 실패를 하게 됩니다.
npm run test src/exchange/upbit/upbit.service.spec.ts
> auto-coin-trader-bot@0.0.1 test
> jest src/exchange/upbit/upbit.service.spec.ts
FAIL src/exchange/upbit/upbit.service.spec.ts
● Test suite failed to run
src/exchange/upbit/upbit.service.spec.ts:25:20 - error TS2339: Property 'getCurrentPrice' does not exist on type 'UpbitService'.
25 expect(service.getCurrentPrice).toBeDefined();
~~~~~~~~~~~~~~~
Test Suites: 1 failed, 1 total
Tests: 0 total
Snapshots: 0 total
Time: 1.391 s, estimated 2 s
// src/exchange/interfaces/exchange.interface.ts
export interface IExchange {
getCurrentPrice(symbol: string): Promise<number>;
}
// src/exchange/upbit/upbit.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IExchange } from '../interfaces/exchange.interface';
@Injectable()
export class UpbitService implements IExchange {
constructor(private readonly configService: ConfigService) {}
async getCurrentPrice(symbol: string): Promise<number> {
return 0;
}
}
인터페이스 및 최소한의 메소드를 구현했습니다. 테스트를 진행해볼까요?
npm run test src/exchange/upbit/upbit.service.spec.ts
> auto-coin-trader-bot@0.0.1 test
> jest src/exchange/upbit/upbit.service.spec.ts
PASS src/exchange/upbit/upbit.service.spec.ts
UpbitService
✓ should have getCurrentPrice method (5 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 1.705 s
Ran all test suites matching /src\/exchange\/upbit\/upbit.service.spec.ts/i.
Failed가 passed로 바뀌었네요! 원래는 3단계 리팩토링을 진행해야하지만, 지금 단계에서는 필요가 없다고 판단이 됩니다. 다음 단계로 넘어가보겠습니다.
// src/exchange/upbit/upbit.service.spec.ts
...
it('should throw error when symbol is empty', async () => {
await expect(service.getCurrentPrice('')).rejects.toThrow(
'Symbol is required',
);
});
it('should throw error when symbol does not start with KRW-', async () => {
await expect(service.getCurrentPrice('BTC')).rejects.toThrow(
'Invalid symbol format',
);
});
현재는 구현되어있는 코드가 없기에, 당연히 테스트 실패를 한다는 것 다들 알고 계실거라 생각하고, 테스트 결과는 생략하겠습니다.
// src/exchange/upbit/upbit.service.ts
import { BadRequestException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IExchange } from '../interfaces/exchange.interface';
@Injectable()
export class UpbitService implements IExchange {
constructor(private readonly configService: ConfigService) {}
async getCurrentPrice(symbol: string): Promise<number> {
if (!symbol) {
throw new BadRequestException('Symbol is required.');
}
if (!symbol.startsWith('KRW-')) {
throw new BadRequestException('Invalid symbol format');
}
return 0;
}
}
getCurrentPrice 메소드를 수정했습니다. 테스트를 진행해볼까요?
npm run test src/exchange/upbit/upbit.service.spec.ts
> auto-coin-trader-bot@0.0.1 test
> jest src/exchange/upbit/upbit.service.spec.ts
PASS src/exchange/upbit/upbit.service.spec.ts
UpbitService
✓ should have getCurrentPrice method (6 ms)
✓ should throw error when symbol is empty (4 ms)
✓ should throw error when symbol does not start with KRW- (1 ms)
Test Suites: 1 passed, 1 total
Tests: 3 passed, 3 total
Snapshots: 0 total
Time: 1.72 s, estimated 2 s
Ran all test suites matching /src\/exchange\/upbit\/upbit.service.spec.ts/i.
성공입니다.
// src/exchange/upbit/upbit.service.spec.ts
...
it('should throw error when symbol is not in the valid list', async () => {
await expect(service.getCurrentPrice('KRW-INVALID')).rejects.toThrow(
'Invalid symbol: KRW-INVALID',
);
});
// src/exchange/upbit/upbit.service.ts
import { BadRequestException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IExchange } from '../interfaces/exchange.interface';
@Injectable()
export class UpbitService implements IExchange {
private readonly validSymbols = ['KRW-BTC', 'KRW-ETH', 'KRW-XRP'];
constructor(private readonly configService: ConfigService) {}
async getCurrentPrice(symbol: string): Promise<number> {
if (!symbol) {
throw new BadRequestException('Symbol is required.');
}
if (!symbol.startsWith('KRW-')) {
throw new BadRequestException('Invalid symbol format');
}
if (!this.validSymbols.includes(symbol)) {
throw new BadRequestException(`Invalid symbol: ${symbol}`);
}
return 0;
}
}
💡 현재 설정된 심볼들의 의미
1. KRW-BTC
- KRW: 원화 마켓
- BTC: 비트코인 (Bitcoin)
- KRW-ETH
- KRW: 원화 마켓
- ETH: 이더리움 (Ethereum)
- KRW-XRP
- KRW: 원화 마켓
- XRP: 리플 (Ripple)
테스트 목적으로는 이 세 가지 메이저 코인만 설정했지만, 실제로는 더 많은 코인 추가 기능 필요합니다. 나중에는 동적으로 유효한 심볼 목록을 가져오도록 구현할 계획입니다.
테스트 결과입니다. 패스!
npm run test src/exchange/upbit/upbit.service.spec.ts
> auto-coin-trader-bot@0.0.1 test
> jest src/exchange/upbit/upbit.service.spec.ts
PASS src/exchange/upbit/upbit.service.spec.ts
UpbitService
✓ should have getCurrentPrice method (6 ms)
✓ should throw error when symbol is empty (5 ms)
✓ should throw error when symbol does not start with KRW- (1 ms)
✓ should throw error when symbol is not in the valid list (1 ms)
Test Suites: 1 passed, 1 total
Tests: 4 passed, 4 total
Snapshots: 0 total
Time: 1.712 s, estimated 2 s
Ran all test suites matching /src\/exchange\/upbit\/upbit.service.spec.ts/i.
// src/exchange/upbit/upbit.service.spec.ts
...
// 반환 값 검증
describe('return value validation', () => {
beforeEach(() => {
mockedAxios.get.mockResolvedValue({
data: [{ trade_price: 50000000 }],
});
});
it('should return a valid numeric price', async () => {
const price = await service.getCurrentPrice('KRW-BTC');
expect(Number.isFinite(price)).toBeTruthy(); //유한한 숫자인지 검증
});
});
// 실제 가격 범위 검증
describe('price range validation', () => {
beforeEach(() => {
mockedAxios.get.mockImplementation((url, config) => {
const symbol = config?.params?.markets;
let mockPrice: number;
switch (symbol) {
case 'KRW-BTC':
mockPrice = 50000000; // 5천만원
break;
case 'KRW-ETH':
mockPrice = 5000000; // 500만원
break;
case 'KRW-XRP':
mockPrice = 500; // 500원
break;
default:
mockPrice = 0;
}
return Promise.resolve({
data: [{ trade_price: mockPrice }],
});
});
});
it('should return valid BTC price range', async () => {
const price = await service.getCurrentPrice('KRW-BTC');
expect(price).toBeGreaterThan(10000000); // 1천만원
expect(price).toBeLessThan(200000000); // 2억원
});
it('should return valid ETH price range', async () => {
const price = await service.getCurrentPrice('KRW-ETH');
expect(price).toBeGreaterThan(1000000); // 100만원
expect(price).toBeLessThan(10000000); // 1천만원
});
it('should return valid XRP price range', async () => {
const price = await service.getCurrentPrice('KRW-XRP');
expect(price).toBeGreaterThan(100); // 100원
expect(price).toBeLessThan(10000); // 1만원
});
it('should return price with valid decimal places', async () => {
const price = await service.getCurrentPrice('KRW-BTC');
expect(Number.isInteger(price)).toBeTruthy();
});
});
// src/exchange/upbit/upbit.service.ts
...
import { UpbitMarketPrice } from './types/upbit.types';
...
@Injectable()
export class UpbitService implements IExchange {
private readonly validSymbols = ['KRW-BTC', 'KRW-ETH', 'KRW-XRP'];
private readonly apiUrl = 'https://api.upbit.com/v1';
async getCurrentPrice(symbol: string): Promise<number> {
// ... 이전 검증 코드 ...
try {
// 업비트 API 호출
// GET https://api.upbit.com/v1/ticker?markets=KRW-BTC
const { data } = await axios.get<UpbitMarketPrice[]>(
`${this.apiUrl}/ticker`,
{
params: { markets: symbol },
},
);
if (!data?.[0]) {
throw new BadRequestException('Failed to get price data');
}
const price = data[0].trade_price;
// 가격 범위 검증
if (!this.isValidPrice(symbol, price)) {
throw new BadRequestException(`Invalid price range for ${symbol}`);
}
return price;
} catch (error) {
// ... 에러 처리 ...
}
}
// 코인별 가격 범위 검증 메서드
private isValidPrice(symbol: string, price: number): boolean {
const ranges = {
'KRW-BTC': { min: 10000000, max: 200000000 }, // 1천만원 ~ 2억원
'KRW-ETH': { min: 1000000, max: 10000000 }, // 100만원 ~ 1천만원
'KRW-XRP': { min: 100, max: 10000 }, // 100원 ~ 1만원
};
const range = ranges[symbol];
return price >= range.min && price <= range.max && Number.isInteger(price);
}
}
[
{
"market": "KRW-BTC",
"trade_date": "20240822",
"trade_time": "071602",
"trade_date_kst": "20240822",
"trade_time_kst": "161602",
"trade_timestamp": 1724310962713,
"opening_price": 82900000,
"high_price": 83000000,
"low_price": 81280000,
"trade_price": 82324000,
"prev_closing_price": 82900000,
"change": "FALL",
"change_price": 576000,
"change_rate": 0.0069481303,
"signed_change_price": -576000,
"signed_change_rate": -0.0069481303,
"trade_volume": 0.00042335,
"acc_trade_price": 66058843588.46906,
"acc_trade_price_24h": 250206655398.15125,
"acc_trade_volume": 803.00214714,
"acc_trade_volume_24h": 3047.01625142,
"highest_52_week_price": 105000000,
"highest_52_week_date": "2024-03-14",
"lowest_52_week_price": 34100000,
"lowest_52_week_date": "2023-09-11",
"timestamp": 1724310962747
},
{
"market": "KRW-ETH",
"trade_date": "20240822",
"trade_time": "071600",
"trade_date_kst": "20240822",
"trade_time_kst": "161600",
"trade_timestamp": 1724310960320,
"opening_price": 3564000,
"high_price": 3576000,
"low_price": 3515000,
"trade_price": 3560000,
"prev_closing_price": 3564000,
"change": "FALL",
"change_price": 4000,
"change_rate": 0.0011223345,
"signed_change_price": -4000,
"signed_change_rate": -0.0011223345,
"trade_volume": 0.00281214,
"acc_trade_price": 14864479133.80843,
"acc_trade_price_24h": 59043494176.58761,
"acc_trade_volume": 4188.3697943,
"acc_trade_volume_24h": 16656.93091147,
"highest_52_week_price": 5783000,
"highest_52_week_date": "2024-03-13",
"lowest_52_week_price": 2087000,
"lowest_52_week_date": "2023-10-12",
"timestamp": 1724310960351
}
]
해당 내용은 업비트 개발자센터를 참고하시면 더 정확히 보실 수 있습니다.
테스트 결과입니다. 휴, 다행히 성공이네요.
npm run test src/exchange/upbit/upbit.service.spec.ts
> auto-coin-trader-bot@0.0.1 test
> jest src/exchange/upbit/upbit.service.spec.ts
PASS src/exchange/upbit/upbit.service.spec.ts
UpbitService
✓ should have getCurrentPrice method (6 ms)
symbol validation
✓ should throw error when symbol is empty (7 ms)
✓ should throw error when symbol does not start with KRW- (1 ms)
✓ should throw error when symbol is not in the valid list (1 ms)
return value validation
✓ should return a valid numeric price (1 ms)
price range validation
✓ should return valid BTC price range (1 ms)
✓ should return valid ETH price range (1 ms)
✓ should return valid XRP price range (1 ms)
✓ should return price with valid decimal places (2 ms)
Test Suites: 1 passed, 1 total
Tests: 9 passed, 9 total
Snapshots: 0 total
Time: 1.739 s, estimated 2 s
Ran all test suites matching /src\/exchange\/upbit\/upbit.service.spec.ts/i.
// src/exchange/upbit/upbit.service.spec.ts
...
// API 통합 테스트
describe('API integration', () => {
// 올바른 엔드포인트로 API 호출하는지 검증
it('should make API call to correct endpoint', async () => {
mockedAxios.get.mockResolvedValueOnce({
data: [{ trade_price: 50000000 }],
});
await service.getCurrentPrice('KRW-BTC');
expect(mockedAxios.get).toHaveBeenCalledWith(
'https://api.upbit.com/v1/ticker', // 정확한 엔드포인트 URL
expect.any(Object), // config 객체는 별도 테스트
);
});
// 요청 파라미터 검증
it('should include markets parameter in API call', async () => {
mockedAxios.get.mockResolvedValueOnce({
data: [{ trade_price: 50000000 }],
});
await service.getCurrentPrice('KRW-BTC');
expect(mockedAxios.get).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
params: { markets: 'KRW-BTC' }, // markets 파라미터 검증
}),
);
});
// 빈 응답 처리 검증
it('should handle empty response data', async () => {
mockedAxios.get.mockResolvedValueOnce({ data: [] });
await expect(service.getCurrentPrice('KRW-BTC')).rejects.toThrow(
'Failed to get price data',
);
});
// API 에러 처리 검증
it('should handle API error', async () => {
const error = new Error('API request failed') as any;
error.isAxiosError = true;
error.response = {
data: {
error: {
message: 'API request failed',
},
},
};
mockedAxios.get.mockRejectedValueOnce(error);
await expect(service.getCurrentPrice('KRW-BTC')).rejects.toThrow(
'Failed to fetch price: API request failed',
);
});
});
💡 API 통합 테스트의 주요 검증 포인트
1. 올바른 엔드포인트 URL 사용
2. 필요한 요청 파라미터 포함
3. 빈 응답 데이터 처리
4. API 에러 상황 처리
// src/exchange/upbit/upbit.service.ts
...
async getCurrentPrice(symbol: string): Promise<number> {
// ... 심볼 검증 ...
try {
const { data } = await axios.get<UpbitMarketPrice[]>(
`${this.apiUrl}/ticker`, // 올바른 엔드포인트
{
params: { markets: symbol }, // 필요한 파라미터
},
);
if (!data?.[0]) { // 빈 응답 처리
throw new BadRequestException('Failed to get price data');
}
// ... 가격 검증 ...
return data[0].trade_price;
} catch (error: any) { // API 에러 처리
const errorMessage =
error?.response?.data?.error?.message || error.message;
throw new BadRequestException(`Failed to fetch price: ${errorMessage}`);
}
}
테스트 결과입니다.
npm run test src/exchange/upbit/upbit.service.spec.ts
> auto-coin-trader-bot@0.0.1 test
> jest src/exchange/upbit/upbit.service.spec.ts
PASS src/exchange/upbit/upbit.service.spec.ts
UpbitService
✓ should have getCurrentPrice method (6 ms)
symbol validation
✓ should throw error when symbol is empty (6 ms)
✓ should throw error when symbol does not start with KRW- (5 ms)
✓ should throw error when symbol is not in the valid list (2 ms)
return value validation
✓ should return a valid numeric price (1 ms)
price range validation
✓ should return valid BTC price range (1 ms)
✓ should return valid ETH price range (1 ms)
✓ should return valid XRP price range (1 ms)
✓ should return price with valid decimal places (2 ms)
API integration
✓ should make API call to correct endpoint (1 ms)
✓ should include markets parameter in API call (1 ms)
✓ should handle empty response data
✓ should handle API error (1 ms)
Test Suites: 1 passed, 1 total
Tests: 13 passed, 13 total
Snapshots: 0 total
Time: 1.849 s, estimated 2 s
Ran all test suites matching /src\/exchange\/upbit\/upbit.service.spec.ts/i.
실시간 시세 조회 기능을 완성하였습니다. 그러면 이제 mock-test가 아니라 실제로 서비스의 메소드가 잘작동하는지 테스트를 해봐야겠죠? 이는 e2e (end-to-end) 테스트를 통해 진행해보겠습니다!
/test/exchange.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { UpbitService } from '../src/exchange/upbit/upbit.service';
describe('Exchange E2E Test', () => {
let app: INestApplication;
let upbitService: UpbitService;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forRoot()],
providers: [UpbitService],
}).compile();
app = moduleFixture.createNestApplication(); // NestJs 인스턴스 생성
await app.init();
upbitService = moduleFixture.get<UpbitService>(UpbitService);
});
afterAll(async () => {
await app.close();
});
describe('Upbit Price API', () => {
const symbols = ['KRW-BTC', 'KRW-ETH', 'KRW-XRP'];
// 각 심볼에 대해 테스트
symbols.forEach((symbol) => {
it(`should fetch real ${symbol} price`, async () => {
const price = await upbitService.getCurrentPrice(symbol);
// 가격이 숫자인지 확인
expect(typeof price).toBe('number');
// 가격이 양수인지 확인
expect(price).toBeGreaterThan(0);
// 정수인지 확인
expect(Number.isInteger(price)).toBe(true);
console.log(`${symbol} price: ${price.toLocaleString()}원`);
}, 10000);
// 타임아웃 10초, 원래 기본 값은 5초이지만, e2e 테스트시에는 네트워크를 이용하기 때문에,
// 시간이 더 많이 소요될 것으로 예상되어 10초로 설정.
});
});
});
npm run test:e2e test/exchange.e2e-spec.ts
> auto-coin-trader-bot@0.0.1 test:e2e
> jest --config ./test/jest-e2e.json test/exchange.e2e-spec.ts
console.log
KRW-BTC price: 138,608,000원
at Object.<anonymous> (exchange.e2e-spec.ts:43:17)
console.log
KRW-ETH price: 5,583,000원
at Object.<anonymous> (exchange.e2e-spec.ts:43:17)
console.log
KRW-XRP price: 3,436원
at Object.<anonymous> (exchange.e2e-spec.ts:43:17)
PASS test/exchange.e2e-spec.ts
Exchange E2E Test
Upbit Price API
✓ should fetch real KRW-BTC price (91 ms)
✓ should fetch real KRW-ETH price (41 ms)
✓ should fetch real KRW-XRP price (32 ms)
Test Suites: 1 passed, 1 total
Tests: 3 passed, 3 total
Snapshots: 0 total
Time: 2.062 s
Ran all test suites matching /test\/exchange.e2e-spec.ts/i.
TDD의 길은 멀고도 험하군여...
그 다음은 잔고 조회 기능 구현으로 찾아오겠습니다.