자바스크립트 테스트 작성 - Jest

dobby·2024년 10월 25일
0
post-thumbnail

테스트란

작성한 코드가 원하는 동작을 하는지 확인하는 검증 과정을 말한다.

테스트 종류

  • 단위 테스트: 작은 단위(1개의 모듈)의 모듈 테스트
  • 통합 테스트: 2개 이상의 모듈 간의 상호작용을 테스트 (개발자 관점의 테스트)
  • E2E 테스트: 사용자의 실행 환경과 거의 동일한 환경에서의 테스트 (사용자 관점의 테스트)

자바스크립트 테스트 도구

  1. Test Runners
  2. Testing Frameworks
  3. Assertion Libraries
  4. Testing Plugins
  • 테스트를 구동할 수 있는 환경을 제공하는 Test Runners,
  • 테스트 코드 작성을 위한 기반을 만들어주는 Testing Frameworks,
  • 실제로 테스트 할 항목이 테스트 하려는 것과 동일한지 체크할 수 있게 해주는 Assertion Libraries,
  • mocks, stubs, fake servers를 만들 수 있게 해주는 Testing Plugins 가 있다.

자바스크립트 테스트 환경

자바스크립트 테스트는 브라우저 환경Node.js 환경 에서 실행할 수 있다.

브라우저 환경과는 달리, Node.js 환경에서 돌아가는 테스트 러너들은 테스트 러너의 실행 환경과 테스트 코드의 실행 환경을 구분할 필요가 없기 때문에 (둘다 Node.js) 대부분 테스트 프레임워크로써 통합된 형태로 제공된다.

즉, Mocha , Jest , Jasmine 은 Node.js 환경에서 돌아가는 테스트 프레임워크이다.


Test Runner 정리

  • 자바스크립트 테스트는 브라우저 환경과 Node.js 환경에서 실행할 수 있다.
  • 브라우저 환경
    • 장점:
      • 브라우저의 모든 기능(네트워크 IO, 렌더링 엔진 등)을 활용해서 테스트할 수 있다.
    • 단점:
      • 테스트의 초기 구동 속도가 느리다. (Node.js 프로세스보다 무거움)
      • 모듈 단위의 테스트를 실행하기가 어렵다 (번들러 사용 필요)
  • Node.js 환경 (= Testing Framework)
    • 종류
      • Jest
      • Mocha
      • Jasmine
    • 장점:
      • 속도가 비교적 빠르다. (브라우저 프로세스에 비해 가벼움)
      • 원하는 모듈만 가져와서 테스트할 수 있기 때문에 간단하고 안전한 방식으로 테스트가 가능하다.
    • 단점:
      • 브라우저의 모든 API를 제대로 활용할 수 없음 (jsdom과 같은 라이브러리를 사용해서 브라우저 환경을 가상으로 구현하는 방식 사용)

Testing Framework

Jest

  • 페이스북에서 만든 테스팅 라이브러리
  • React에서 많이 사용
  • Jasmine에 기반을 둠
  • Test Runner, Test Matcher, Test Mock 프레임워크까지 제공

Mocha

  • Node.js 테스트를 위해 나옴 (백엔드 테스트에 강점)
  • Setup 과정이 Jest나 Jasmine에 비해 더 복잡함
  • Jest나 Jasmine보다 유연함
  • 확장성이 좋음

Jasmine

  • Node.js 테스트를 위해 나옴 (백엔드 테스트에 강점)
  • Angular에서 많이 사용

npm trends를 보면 Jest가 Test RunnerTest Mathcher, Test Mock 프레임워크까지 제공해주기 때문에 많은 개발자가 사용하는 것을 알 수 있다.


Jest

타입스크립트, React, 바벨 사용 등의 추가 종속성이 필요하다면 아래의 Jest 공식문서를 보면 설치할 수 있다.

https://jestjs.io/docs/getting-started

기본적인 jest 설치는 아래와 같다.

npm install --save-dev jest

설치 후 package.json 파일을 열어 test 스크립트를 jest로 추가해준다.

"test" :"jest"

npm test 명령어 입력 시 테스트 스크립트가 실행된다.

Jest는 기본적으로 test.js로 끝나거나(ex> component.test.js) __**test__** 디렉터리 안에 있는 파일들을 모두 테스트 파일로 인식한다.

만약 특정 테스트 파일만 실행하고 싶은 경우에는 npm test <파일명 이나 경로> 를 입력하여 실행할 수 있다.

기본 구성

test("테스트 설명", () => {
	expect(실행할 함수 또는 값).(method);
});

describe("특정 범위 관련 작업", () => {
	test("테스트 설명", () => {
		expect(실행할 함수 또는 값).(method);
	});
});

테스트를 실행하면 터미널에 위 사진처럼 실패한 테스트, 성공한 테스트가 출력된다.

Matcher

  • toBe() 기본 타입 값을 비교할 때 사용한다.
    test("2 더하기 3은 5", () => {
    	expect(2 + 3).toBe(5);
    });
  • toEqual() toBe와 비슷하게 값을 비교할 때 사용한다. 하지만, toBe는 기본 타입을 비교할 때 사용하고, toEqual은 객체를 비교할 때 사용한다.
    const user = {
    	name: 'dobby',
    	age: 25,
    }
    
    test("user 객체 비교", () => {
    	expect(user).toEqual({
    		name: 'dobby',
    		age: 25,
    	})
    });
    toEqual보다는 깊은 비교를 위해 toStrictEqual을 사용하는 것이 좋다.
    const user = {
    	name: 'dobby',
    	age: 25,
    	gender: undefined
    }
    
    test("user 객체 비교", () => {
    	expect(user).toEqual({
    		name: 'dobby',
    		age: 25,
    	})
    }); // 성공
    
    test("user 객체 비교", () => {
    	expect(user).toStrictEqual({
    		name: 'dobby',
    		age: 25,
    	})
    }); // 실패
  • toBeNull()
  • toBeUndefined()
  • toBeDefined()
  • toBeTruthy()
  • toBeFalsy()
  • toBeGreaterThan() // 크다
  • toBeGreaterThanOrEuql // 크거나 같다
  • toBeLessThan // 작다
  • toBeLessThanOrEqual // 작거나 같다
  • toBeCloseTo() 0.1 + 0.2를 출력하면 컴퓨터는 이진법을 사용하기 때문에 소수를 이진법으로 바꿨을 때 무한 소수가 되는 경우가 있다. ex) 0.1 + 0.2 = 0.30000000000000004 이때 사용해주는 메소드이다.
    test("0.1 + 0.2 = 0.3", () => {
    	expect(0.1 + 0.2).toBeCloseTo(0.3);
    });
  • toMatch() 문자열의 정규표현식을 사용할 수 있다.
    test("Hello world에 a라는 글자가 있나?", () => {
    	expect("Hello world").toMatch(/a/);
    }); // 실패
    
    test("Hello world에 h라는 글자가 있나?", () => {
    	expect("Hello world").toMatch(/h/i);
    }); // 성공
  • toContain() 배열에 특정 원소가 있는지 확인한다.
    test("Hello world에 h라는 글자가 있나?", () => {
    	const user = "mike";
    	const userList = ["tom", "jane", "kai"];	
    	expect(userList).toContain(user);
    }); // 실패
  • toThrow() 에러가 발생했는지 확인한다.
    const makeError = () => {
    	throw new Error("[Error] 에러 발생!");
    }
    
    test("에러 발생", () => {
    	expect(() => makeError()).toThrow();
    }); // 성공
    
    test("에러 발생", () => {
    	expect(() => makeError()).toThrow("[Error]");
    }); // 성공, 특정 에러인지 확인
    
    // 비동기 코드 실행 후 에러 발생 확인
    test("에러 발생", async () => {
    	const app = new App();
    	await expect(app.run()).rejects.toThrow("[Error]");
    }); // 성공, 특정 에러인지 확인

비동기 코드 테스트

  • 콜백 테스트
const fn = require("./fn");

test("3초 후에 받아온 이름은 mike", (done) => {
	function callback(name) {
		expect(name).toBe("Tom");
		done(); // done이 호출되기 전까지 Jest는 실행을 끝내지 않는다.
	}
	fn.getName(callback);
});

만약, done을 전달은 받았는데, 호출하지 않으면 테스트가 실패하게 된다.

  • promise 테스트

promise를 넘겨주면 jest는 실행이 끝날 때까지 기다려준다.

const getAge() => {
	const age = 30;
	return new Promise((res, rej) => {
		setTimeout(() => {
			res(age);
			// throw new Error("[ERROR]");
		}, 3000);
	})
}

// promise를 사용할 떄는 return을 사용해줘야 한다.
test("3초 후 나이 30", () => {
	return getAge().then(age => {
		expect(age).toBe(30);
	});
});

// matcher를 사용했을 때
test("3초 후 나이 30", () => {
		expect(age).resolves.toBe(30);
		// expect(age).rejects.toThrow("[ERROR]");
});
  • async await 테스트
test("3초 후 나이 30", async () => {
		const age = await getAge();
		expect(age).toBe(30);
		// await expect(age).resolves.toBe(30);
});

테스트 전후 작업

  • 각 테스트 실행 직전에 실행
    beforeEach(() => {
    	// 코드 작성
    });
  • 각 테스트 실행 직후에 실행
    afterEach(() => {
    	// 코드 작성
    });
  • 모든 테스트 실행 전에 실행
    beforeAll(() => {
    	// 실행
    });
  • 모든 테스트 실행 후에 실행
    afterAll(() => {
    	// 실행
    });

특정 테스트 넘기기, 특정 테스트만 실행

  • only 특정 작업만 실행하고 나머지는 skip된다.
    test("3초 후 나이 30", async () => {
    		const age = await getAge();
    		expect(age).toBe(30);
    		// await expect(age).resolves.toBe(30);
    });
    
    test("3초 후 나이 30", async () => {
    		const age = await getAge();
    		expect(age).toBe(30);
    		// await expect(age).resolves.toBe(30);
    });
    
    test.only("3초 후 나이 30", async () => {
    		const age = await getAge();
    		expect(age).toBe(30);
    		// await expect(age).resolves.toBe(30);
    });
  • skip 특정 테스트를 넘긴다.
    test.skip("3초 후 나이 30", async () => {
    		const age = await getAge();
    		expect(age).toBe(30);
    		// await expect(age).resolves.toBe(30);
    });
    
    test("3초 후 나이 30", async () => {
    		const age = await getAge();
    		expect(age).toBe(30);
    		// await expect(age).resolves.toBe(30);
    });

Mock 함수

테스트 하기 위해 흉내만 내는 함수

mocking이란 단위 테스트를 작성할 때, 해당 코드가 의존하는 부분을 가짜(mock)로 대체하는 기법을 말한다.

일반적으로 테스트하려는 코드가 의존하는 부분을 직접 생성하기가 너무 부담스러운 경우 mocking을 많이 사용한다.

예를 들어, 데이터베이스에서 데이터를 삭제하는 코드에 대한 단위 테스트를 작성할 때, 실제 데이터베이스를 사용한다면 여러 문제점이 발생할 수 있다.

jest.fn() 으로 mock 함수를 만들 수 있다.

const mockFn = jest.fn();

mockFn();
mockFn(1);
mockFn(11);

// mockFn.mock 에는 호출되었던 값들이 저장된다.
// [[], [1], [11]]

// mockFn.mock.calls
// 모든 호출의 호출 인수를 포함하는 배열
// 이 메소드로 알 수 있는 것은 두 가지이다.
// 1. 함수가 총 몇 번 호출되었는가
// 2. 호출될 때 전달된 인수는 무엇인가

test("call 길이", () => {
	console.log(mockFn.mock.results); // type과 value가 들어있는 배열
	expect(mockFn.mock.calls.length).toBe(3);
	expect(mockFn.mock.calls[1][0].toBe(1);	
	expect(mockFn.mock.results[2].value.toBe(11);
}); // 성공

// mockFn.mock.results 결과값
/*
[
  {
    type: 'return',
    value: ,
  },
  {
    type: 'return',
    value: 1
  },
  {
    type: 'return',
    value: 11,
  },
];

*/

실행할 때마다 각각 다른 값을 리턴해줄 수도 있다.

const mockFn = jest.fn();

mockFn
.mockReturnValueOnce(10)
.mockReturnValueOnce(20);
.mockReturnValue(30);

mockFn();
mockFn();
mockFn();

test("call 길이", () => {
	console.log(mockFn.mock.results);
	expect(mockFn.mock.calls.length).toBe(3);
}); // 성공
const mockFn = jest.fn();

mockFn
.mockReturnValueOnce(true);
.mockReturnValueOnce(false);
.mockReturnValueOnce(true);
.mockReturnValueOnce(false);
.mockReturnValueOnce(true);

const result = [1, 2, 3, 4, 5].filter(num => mockFn(num));

test("홀수는 1,3,5", () => {
	expect(result).toStrictEqual([1,3,5]);
}); // 성공

비동기 함수를 흉내낼 수도 있다.

const mockFn = jest.fn();

mockFn.mockResolvedValue({name: 'mike'});

test("받아온 이름은 Mike", () => {
	mockFn().then(res => {
		expect(res.name).toBe("mike");
	});
}); // 성공

test('async test', async () => {
  const asyncMock = jest
    .fn()
    .mockResolvedValue('default')
    .mockResolvedValueOnce('first call')
    .mockResolvedValueOnce('second call');

  await asyncMock(); // 'first call'
  await asyncMock(); // 'second call'
  await asyncMock(); // 'default'
  await asyncMock(); // 'default'
});

// 항상 거부하는 비동기 mock 함수
test('async test', async () => {
  const asyncMock = jest
    .fn()
    .mockRejectedValue(new Error('Async error message'));

  await asyncMock(); // throws 'Async error message'
});

test('async test', async () => {
  const asyncMock = jest
    .fn()
    .mockResolvedValueOnce('first call')
    .mockRejectedValueOnce(new Error('Async error message'));

  await asyncMock(); // 'first call'
  await asyncMock(); // throws 'Async error message'
});
const mockFn = jest.fn();

mockFn(10, 20);
mockFn();
mockFn(30, 40);

test("한 번이라도 호출됐으면 통과", () => {
	expect(mockFn).toBeCalled();
}); // 성공

test("3번 호출됐으면 통과", () => {
	expect(mockFn).toBeCalledTimes(3);
}); // 성공

test("호출한 값이 포함하면 통과", () => {
	expect(mockFn).toBeCalledWith(10, 20);
}); // 성공

test("마지막으로 호출된 함수의 인수가 같으면 통과", () => {
	expect(mockFn).lastCalledWith(30, 40);
}); // 성공
  • mockFn.mockImplementation(fn)

jest.fn(implementation)은 jest.fn().mockImplementation(implementation)과 같다.

const mockFn = jest.fn(scalar => 42 + scalar);

mockFn(0); // 42
mockFn(1); // 43

mockFn.mockImplementation(scalar => 36 + scalar);

mockFn(2); // 38
mockFn(3); // 39

---------------------------------------------------

const mockFn = jest
  .fn(() => 'default')
  .mockImplementationOnce(() => 'first call')
  .mockImplementationOnce(() => 'second call');

mockFn(); // 'first call'
mockFn(); // 'second call'
mockFn(); // 'default'
mockFn(); // 'default'
  • mockFn.mockName(name)
const mockFn = jest.fn().mockName('mockedFunction');

// mockFn();
expect(mockFn).toHaveBeenCalled();

expect(mockedFunction).toHaveBeenCalled()

/*
Expected number of calls: >= 1
Received number of calls:    0
*/
  • mockFn.mockReturnValue(value)

mock 함수가 호출될 때마다 반환되는 값을 받는다.


// jest.fn().mockImplementation(() => value);

const mock = jest.fn();

mock.mockReturnValue(42);
mock(); // 42

mock.mockReturnValue(43);
mock(); // 43

// mockReturnValueOnce를 먼저 출력하고, 더이상 출력할 Once가 없으면 
// mockReturnValue를 출력한다.
const mockFn = jest
  .fn()
  .mockReturnValue('default')
  .mockReturnValueOnce('first call')
  .mockReturnValueOnce('second call');

mockFn(); // 'first call'
mockFn(); // 'second call'
mockFn(); // 'default'
mockFn(); // 'default'
  • spyOn()

어떤 객체에 속한 함수의 구현을 가짜로 대체하지 않고, 해당 함수의 호출 여부와 어떻게 호출되었는지만을 알아내야 할 때 사용한다.

jest.spyOn(object, methodName)

const calculator = {
  add: (a, b) => a + b,
};

const spyFn = jest.spyOn(calculator, "add");

const result = calculator.add(2, 3);

expect(spyFn).toHaveBeenCalledTimes(1);
expect(spyFn).toHaveBeenCalledWith(2, 3);
expect(result).toBe(5);

// 모두 통과

jest.spyOn() 함수를 이용해서 calculator 객체의 add 라는 함수에 스파이를 붙였다.

따라서 add 함수 호출 후에 호출 횟수와 어떤 인자가 넘어갔는지 검증할 수 있다.

하지만 가짜 함수로 대체한 것은 아니기 때문에 여전히 결과 값은 원래 구현대로 2와 3의 합인 5가 된다.

사용했던 mock을 정리

테스트 대역은 구현 코드를 테스트하는데 필요한 것(객체, 함수, 데이터 등)들을 테스트를 실행하는 동안 대신하는 요소들을 말한다. 이는 mock을 의미한다.

다음 테스트 케이스를 실행하기 전에는 현재 테스트 케이스에서 사용했던 Mock을 정리해주는게 좋다.
다음 테스트 케이스에 영향을 줄 수도 있기 때문이다.

수동으로 mock을 정리하는 방법

  • mockFn.mockClear
  • mockFn.mockReset
  • mockFn.mockRestore

jest로 mock 정리하기

  • jest.clearMocks (모든 mock 함수에서 mockFn.clearAllMocks 호출)
    • mocks.callsmock.instances를 초기화한다.
    • 테스트 케이스를 실행하기 전에 Mock 함수를 호출했던 정보를 비우고 싶을 때 유용하다.
  • jest.resetMocks (모든 mock 함수에서 mockFn.resetAllMocks 호출)
    • mockClear (clearMocks) 함수가 하는 일을 모두 할 수 있다.
    • mock 함수의 구현(jest.fn()에 넘기는 함수)을 undefined를 반환하는 반환하는 빈 함수로 초기화한다.
  • jest.restoreMocks (모든 mock 함수에서 mockFn.restoreAllMocks 호출)
    • mockReset (resetMocks) 함수가 하는 일을 모두 할 수 있다.
    • mocking하면서 오염된 함수를 다시 원래대로 되돌릴 수 있다.
    • 객체의 메서드를 mocking 했다면 mockRestore(restoreMocks)를 호출하여 원래대로 되돌려줘야 한다.

beforeEach, afterEach와 같은 함수로 테스트 케이스가 실행되기 전이나 후에 정리해주는 방법도 있다.

beforeEach(() => {
  jest.restoreAllMocks();
});

우아한테크코스 프리코스

프리코스 1-4주차 미션에서 제공된 테스트 코드를 분석해보자.

프리코스 1주차 - calculator ApplicationTest.js

import { MissionUtils } from '@woowacourse/mission-utils';
import App from '../src/App.js';

const mockQuestions = (inputs) => {
  // MissionUtils.Console.readLineAsync: 사용자로부터 데이터를 입력받는 함수
  MissionUtils.Console.readLineAsync = jest.fn(); // mockFn 할당

  // mock 함수로 할당한 MissionUtils.Console.readLineAsync이 호출될 때마다 실행되도록 한다.
  MissionUtils.Console.readLineAsync.mockImplementation(() => {
    const input = inputs.shift(); // 배열로 전달받은 데이터 추출
    return Promise.resolve(input); // Promise는 return 해줘야 정상작동 한다.
  });
};

const getLogSpy = () => {
  // spyOn(객체, 메소드 이름)
  // MissionUtils.Console의 print 메서드를 지켜본다.
  const logSpy = jest.spyOn(MissionUtils.Console, 'print');
  logSpy.mockClear(); // mock 데이터를 지운다.
  return logSpy;
};

describe('문자열 계산기', () => {
  test('커스텀 구분자 사용', async () => { // async/await 사용
    const inputs = ['//;\\n1'];
    mockQuestions(inputs);

    const logSpy = getLogSpy(); // MissionUtils.Console spy 리턴
    const outputs = ['결과 : 1'];

    const app = new App();
    await app.run(); // App 클래스 안에서 readLineAsync, Console.print 사용
    // readLineAsync 호출로 인한 입력값은 inputs로 전달받은 데이터로 대체

    outputs.forEach((output) => {
      // MissionUtils.Console.print가 output을 인자로 호출되었는지 확인
      // expect.stringContaining: 전달받은 값이 인자로 받은 string과 일치하는지 여부를 확인
      expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(output));
    });
  });

  test('예외 테스트', async () => {
    const inputs = ['-1,2,3'];
    mockQuestions(inputs);

    const app = new App();

    // App을 실행하면서 비동기 rejects 처리되고 [ERROR] message를 가진 에러를 반환하는지 확인
    await expect(app.run()).rejects.toThrow('[ERROR]');
  });
});

프리코스 2주차 - racingcar ApplicationTest.js

// 입력을 받는 함수
const mockQuestions = (inputs) => {
    MissionUtils.Console.readLineAsync = jest.fn(); // mockFn 할당

	// mock 함수가 호출될 때마다 실행되도록 한다.
    MissionUtils.Console.readLineAsync.mockImplementation(() => {
        const input = inputs.shift(); // 받아온 입력값
        return Promise.resolve(input); // 비동기로 전달
    });
};

// 랜덤한 값을 만드는 함수
const mockRandoms = (numbers) => {
    MissionUtils.Random.pickNumberInRange = jest.fn(); // mockFn 할당

    // 초기값 할당 및 item에 대해 모두 적용하기 위해 reduce 사용
    numbers.reduce((acc, number) => {
	// mock 함수 호출 시 number 값을 차례로 반환한다.
	// 초기값에 의해 아래와 같은 뜻이다.
	// MissionUtils.Random.pickNumberInRange.mockReturnValueOnce(number);
        return acc.mockReturnValueOnce(number);
    }, MissionUtils.Random.pickNumberInRange);
};

const getLogSpy = () => {
	// app에서 실행되는 MissionUtils.Console.print를 감시하여 호출 여부 판단 및 전달받은 인자 확인
	// MissionUtils.Console의 가짜 함수
    const logSpy = jest.spyOn(MissionUtils.Console, "print");
    // mock에 대한 정보를 지운다.
	logSpy.mockClear();
  
    return logSpy;
};

test("기능 테스트", async () => {
    const MOVING_FORWARD = 4; // 4이상이면 전진
    const STOP = 3; // 3이하는 가만히
    const inputs = ["pobi,woni", "1"]; // 사용자 입력
    const logs = ["pobi : -", "woni : ", "최종 우승자 : pobi"]; // 출력값
    const logSpy = getLogSpy(); // Console의 스파이 함수 리턴

    mockQuestions(inputs); // 지정한 입력값을 전달
	// inputs 배열의 아이템 값들을 mock 함수 실행시 차례로 반환
	// 각 자동차가 전진할 것인지, 멈출 것인지를 지정
    // MissionUtils.Random.pickNumberInRange가 호출되면 배열의 값들을 차례로 반환
    mockRandoms([MOVING_FORWARD, STOP]); 

	// app 실행
    const app = new App();
    await app.run();

	// app.js 파일에서 실행되는 Console을 감시
    logs.forEach((log) => {
	// app.js 파일을 실행한 결과의 출력에 log와 일치하는 것이 있는지 확인
        expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log));
    });
});

test("예외 테스트", async () => {
    const inputs = ["pobi,javaji"];
	// 지정한 입력값 전달
    mockQuestions(inputs);

    const app = new App();

	// app을 실행한 결과 [ERROR] 메시지를 가진 에러를 반환하는지 확인
    await expect(app.run()).rejects.toThrow("[ERROR]");
});

테스트 돌린 결과

프리코스 3주차 - lotto ApplicationTest.js

import { MissionUtils } from '@woowacourse/mission-utils';
import App from '../src/App.js';

const mockQuestions = (inputs) => {
  MissionUtils.Console.readLineAsync = jest.fn();

  MissionUtils.Console.readLineAsync.mockImplementation(() => {
    const input = inputs.shift();

    return Promise.resolve(input);
  });
};

// 랜덤값 생성
const mockRandoms = (numbers) => {
  // MissionUtils.Random.pickUniqueNumbersInRange: 1-45 사이 유니크한 랜덤값 생성
  MissionUtils.Random.pickUniqueNumbersInRange = jest.fn(); // mockFn 할당
  numbers.reduce(
    // 초기값에 의해 MissionUtils.Random.pickUniqueNumbersInRange.mockReturnValueOnce(number)와 동일
    (acc, number) => acc.mockReturnValueOnce(number),
    MissionUtils.Random.pickUniqueNumbersInRange,
  );
};

// app에서 실행되는 MissionUtils.Console.print를 감시하여 호출 여부 판단 및 전달받은 인자 확인
const getLogSpy = () => {
  const logSpy = jest.spyOn(MissionUtils.Console, 'print');
  logSpy.mockClear();
  return logSpy;
};

// 예외 테스트
const runException = async (input) => {
  const logSpy = getLogSpy();

  const RANDOM_NUMBERS_TO_END = [1, 2, 3, 4, 5, 6];
  const INPUT_NUMBERS_TO_END = ['1000', '1,2,3,4,5,6', '7'];

  mockRandoms([RANDOM_NUMBERS_TO_END]); // [1, 2, 3, 4, 5, 6]을 랜덤한 값으로 지정
  mockQuestions([input, ...INPUT_NUMBERS_TO_END]); // 전달받은 입력값을 먼저 리턴하도록 지정

  const app = new App();
  await app.run();

  // [ERROR] message를 포함하는 에러를 반환하는지 확인
  expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('[ERROR]'));
};

describe('로또 테스트', () => {
  // 각 테스트 전에 
  beforeEach(() => {
    jest.restoreAllMocks(); // 각 테스트에 영향을 주지 않기 위해 mock 정리
  });

  test('기능 테스트', async () => {
    const logSpy = getLogSpy();

    // MissionUtils.Random.pickUniqueNumbersInRange이 호출될 때마다 각 배열을 리턴
    mockRandoms([
      [8, 21, 23, 41, 42, 43],
      [3, 5, 11, 16, 32, 38],
      [7, 11, 16, 35, 36, 44],
      [1, 8, 11, 31, 41, 42],
      [13, 14, 16, 38, 42, 45],
      [7, 11, 30, 40, 42, 43],
      [2, 13, 22, 32, 38, 45],
      [1, 3, 5, 14, 22, 45],
    ]);
    // 사용자가 입력할 값 설정
    mockQuestions(['8000', '1,2,3,4,5,6', '7']);

    const app = new App();
    await app.run();

    // 콘솔에 출력될 log
    const logs = [
      '8개를 구매했습니다.',
      '[8, 21, 23, 41, 42, 43]',
      '[3, 5, 11, 16, 32, 38]',
      '[7, 11, 16, 35, 36, 44]',
      '[1, 8, 11, 31, 41, 42]',
      '[13, 14, 16, 38, 42, 45]',
      '[7, 11, 30, 40, 42, 43]',
      '[2, 13, 22, 32, 38, 45]',
      '[1, 3, 5, 14, 22, 45]',
      '3개 일치 (5,000원) - 1개',
      '4개 일치 (50,000원) - 0개',
      '5개 일치 (1,500,000원) - 0개',
      '5개 일치, 보너스 볼 일치 (30,000,000원) - 0개',
      '6개 일치 (2,000,000,000원) - 0개',
      '총 수익률은 62.5%입니다.',
    ];

    logs.forEach((log) => {
      // 콘솔에 출력된 문자열이 logs의 각 아이템과 같은지 확인
      expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log));
    });
  });

  test('예외 테스트', async () => {
    await runException('1000j');
  });
});

프리코스 4주차 - convenience ApplicationTest.js

import { MissionUtils } from '@woowacourse/mission-utils';
import { EOL as LINE_SEPARATOR } from 'os';
import App from '../src/App.js';

const mockQuestions = (inputs) => {
  const messages = [];

  MissionUtils.Console.readLineAsync = jest.fn((prompt) => {
    // 출력 메시지를 배열에 저장한다.
    messages.push(prompt);
    const input = inputs.shift();

    if (input === undefined) {
      throw new Error('NO INPUT');
    }

    return Promise.resolve(input);
  });

  MissionUtils.Console.readLineAsync.messages = messages;
};

// 전달받은 날짜를 MissionUtils.DateTimes.now의 반환값으로 설정한다.
const mockNowDate = (date = null) => {
  // MissionUtils.DateTimes.now을 감시하여 호출 여부 판단 및 전달받은 인자 확인
  const mockDateTimes = jest.spyOn(MissionUtils.DateTimes, 'now');
  mockDateTimes.mockReturnValue(new Date(date));
  return mockDateTimes;
};

// MissionUtils.Console.print를 감시하여 호출 여부 판단 및 전달받은 인자 확인
const getLogSpy = () => {
  const logSpy = jest.spyOn(MissionUtils.Console, 'print');
  logSpy.mockClear();
  return logSpy;
};

// 각 배열의 인자값을 개행문자를 기준으로 join
const getOutput = (logSpy) => [...logSpy.mock.calls].join(LINE_SEPARATOR);

// 출력 문자열이 기대하는 문자열과 일치하는지 테스트
const expectLogContains = (received, expects) => {
  expects.forEach((exp) => {
    expect(received).toContain(exp);
  });
};

const expectLogContainsWithoutSpacesAndEquals = (received, expects) => {
  // 공백과 '=' 문자를 제외한 문자열이 기대하는 문자열과 일치하는지 테스트
  const processedReceived = received.replace(/[\s=]/g, '');
  expects.forEach((exp) => {
    expect(processedReceived).toContain(exp);
  });
};

const runExceptions = async ({
  inputs = [],
  inputsToTerminate = [],
  expectedErrorMessage = '',
}) => {
  const logSpy = getLogSpy();
  mockQuestions([...inputs, ...inputsToTerminate]);

  const app = new App();
  await app.run();

  // 전달받은 에러 메시지를 포함하여 출력하는지 테스트
  expect(logSpy).toHaveBeenCalledWith(
    expect.stringContaining(expectedErrorMessage),
  );
};

const run = async ({
  inputs = [],
  inputsToTerminate = [],
  expected = [],
  expectedIgnoringWhiteSpaces = [],
}) => {
  const logSpy = getLogSpy();
  mockQuestions([...inputs, ...inputsToTerminate]);

  const app = new App();
  await app.run();

  // 각 출력값을 개행문자를 기준으로 join
  const output = getOutput(logSpy);

  // expectedIgnoringWhiteSpaces 값이 있다면, 기대 문자열과 출력 문자열이 일치하는지 테스트
  if (expectedIgnoringWhiteSpaces.length > 0) {
    expectLogContainsWithoutSpacesAndEquals(
      output,
      expectedIgnoringWhiteSpaces,
    );
  }
  // expected 값이 있다면, 기대 문자열과 출력 문자열이 일치하는지 테스트
  if (expected.length > 0) {
    expectLogContains(output, expected);
  }
};

const INPUTS_TO_TERMINATE = ['[비타민워터-1]', 'N', 'N'];

describe('편의점', () => {
  afterEach(() => {
    // 각 테스트 이후 mock 정리
    jest.clearAllMocks();
    jest.restoreAllMocks();
  });

  test('파일에 있는 상품 목록 출력', async () => {
    await run({
      inputs: ['[콜라-1]', 'N', 'N'],
      expected: [
        /* prettier-ignore */
        "- 콜라 1,000원 10개 탄산2+1",
        '- 콜라 1,000원 10개',
        '- 사이다 1,000원 8개 탄산2+1',
        '- 사이다 1,000원 7개',
        '- 오렌지주스 1,800원 9개 MD추천상품',
        '- 오렌지주스 1,800원 재고 없음',
        '- 탄산수 1,200원 5개 탄산2+1',
        '- 탄산수 1,200원 재고 없음',
        '- 물 500원 10개',
        '- 비타민워터 1,500원 6개',
        '- 감자칩 1,500원 5개 반짝할인',
        '- 감자칩 1,500원 5개',
        '- 초코바 1,200원 5개 MD추천상품',
        '- 초코바 1,200원 5개',
        '- 에너지바 2,000원 5개',
        '- 정식도시락 6,400원 8개',
        '- 컵라면 1,700원 1개 MD추천상품',
        '- 컵라면 1,700원 10개',
      ],
    });
  });

  test('여러 개의 일반 상품 구매', async () => {
    await run({
      inputs: ['[비타민워터-3],[물-2],[정식도시락-2]', 'N', 'N'],
      expectedIgnoringWhiteSpaces: ['내실돈18,300'],
    });
  });
  //
  test('기간에 해당하지 않는 프로모션 적용', async () => {
    mockNowDate('2024-02-01');
    //
    await run({
      inputs: ['[감자칩-2]', 'N', 'N'],
      expectedIgnoringWhiteSpaces: ['내실돈3,000'],
    });
  });
  //
  test('예외 테스트', async () => {
    await runExceptions({
      inputs: ['[컵라면-12]', 'N', 'N'],
      inputsToTerminate: INPUTS_TO_TERMINATE,
      expectedErrorMessage:
        '[ERROR] 재고 수량을 초과하여 구매할 수 없습니다. 다시 입력해 주세요.',
    });
  });
});

참고 문서
https://spacebike.tistory.com/56
https://soobing.github.io/test/test-introduction/
https://inpa.tistory.com/entry/JEST-%F0%9F%93%9A-jest-%EB%AC%B8%EB%B2%95-%EC%A0%95%EB%A6%AC
https://www.daleseo.com/jest-fn-spy-on/
코딩앙마 유튜브
jest 공식문서

profile
성장통을 겪고 있습니다.

0개의 댓글