Contents

error log - jest.spyOn()에서 재사용함수에 대한 모의 불가


 

사용환경 : Node.Js, Express
언어 : typeScript
테스트 프레임워크 : Jest

상황

Abstract
같은 파일 (모듈) 내에 있는 함수의 재사용시,
테스트 코드를 작성하는데 모의하기가 상당히 까다롭고 불편함.
특히 테스트 코드가 지나치게 지저분해짐

user.service.ts 파일 내부

다음은 user.service.ts 파일의 내부 코드 요약이다.

초점을 맞춰 볼 곳은,
updateUserInfo 함수 내부에서 checkDuplicateEmail 함수와 checkDuplicateNickname 함수가 재사용되고 있다.
 

// ...
const checkDuplicateEmail = async ( email: string ) => {
	...
}

const checkDuplicateNickname = async ( nickname: string ) => {
	...
}

const updateUserInfo = async (userId: number, userInfo: UserDto) => {  
	const originUserInfo = await UserRepository.findOne({  
	where: { id: userId },  
	});  
	  
	if (  
	userInfo.nickname === originUserInfo.nickname &&  
	userInfo.email === originUserInfo.email &&  
	!userInfo.password  
	) {  
	const error = new Error('NO_CHANGE');  
	error.status = 400;  
	throw error;  
	}  
	  
	if (  
	userInfo.nickname &&  
	userInfo.nickname !== originUserInfo.nickname &&  
	!userInfo.password  
	) {  
	// 아래 라인이 동일 파일 내 재사용되는 함수 1.
	await checkDuplicateNickname(userInfo.nickname);  
	}  
	  
	if (userInfo.email && userInfo.email !== originUserInfo.email) {  
	// 아래 라인이 동일 파일 내 재사용되는 함수 2.
	await checkDuplicateEmail(userInfo.email);  
	}  
	  
	if (userInfo.password) {  
	const salt = await bcrypt.genSalt();  
	userInfo.password = await bcrypt.hash(userInfo.password, salt);  
	}  
	  
	await UserRepository.update(userId, userInfo);  
	return await UserRepository.findOne({  
	where: { id: userId },  
	});  
};

 

Question
즉, 내가 원하는 부분은 간단하게
checkDuplicateNickname 함수만을 모의하여
updateUserInfo 함수내부에서 재사용된 checkDuplicateNickname 함수가 호출되었는지 아닌지만 확인하고 싶다.

 

그런데… 문제는
테스트하려는 함수 내에서 이렇게 재사용되는 함수는 모의할 수가 없다는 것이다 !!

때문에 같은 파일 내부에 있는 함수가 재사용되는 경우,
원래의 함수가 아닌, 내부에 있는 재사용 함수는 모의가 되지 않기에
해당 함수의 내부에서 사용되는 메소드를 모두 모의해야하는 상황이 나타난다.
 

user.service.ts 파일 내에 있는 checkDuplicateNickname 함수

const checkDuplicateNickname = async (nickname: string): Promise<object> => {  
	if (!nickname) {  
		const error = new Error(`NICKNAME_IS_UNDEFINED`);  
		error.status = 400;  
		throw error;  
	}  
	// const checkData = await userRepository.findOneBy({ nickname: nickname });  
	const checkData = await User.findByNickname(nickname);  
	  
	if (!checkData) {  
		return { message: 'AVAILABLE_NICKNAME' };  
	}  
	  
	if (checkData.nickname === nickname) {  
		const err = new Error(  
		`${checkData.nickname}_IS_NICKNAME_THAT_ALREADY_EXSITS`  
		);  
		err.status = 409;  
		throw err;  
	}  
};

 

user.service.ts 파일 내에 있는 checkDuplicateNickname 함수

const checkDuplicateEmail = async (email: string): Promise<object> => {  
	if (!email) {  
		const error = new Error(`EMAIL_IS_UNDEFINED`);  
		error.status = 400;  
		throw error;  
	}  
	const checkData = await User.findByEmail(email);  
	  
	if (!checkData) {  
		return { message: 'AVAILABLE_EMAIL' };  
	}  
	  
	if (checkData.email === email) {  
		const err = new Error(`${checkData.email}_IS_EMAIL_THAT_ALREADY_EXSITS`);  
		err.status = 409;  
		throw err;  
	}  
};

 

위에서 언급된 재사용되는 checkDuplicateNickname 함수와 checkDuplicateEmail 함수의 내부를 살펴보면,
User.findByEmail
User.findByNickname
2개의 메소드가 각각 사용되고 있음을 알 수 있다.

 

하지만 이미 checkDuplicateNickname, checkDuplicateEmail 함수는 따로 유닛 테스트 코드가 작성되어있으므로 굳이 해당 함수 내부까지 모의하여 테스트코드를 작성할 필요는 없다.
하지만 현재의 상황에서는 해당 함수 내부까지 모의를 해야만 하는 상황으로 테스트코드가 상당히 지저분해진다.
 

작성된 Jest 테스트 코드

describe('updateUserInfo', () => {  
	const userId = 1;  
	  
	const originUserInfo = {  
		id: userId,  
		nickname: 'oldNickname',  
		email: '[email protected]',  
		password: 'oldPassword',  
	};  
  
	beforeEach(() => {  
		jest.resetAllMocks();  
	  
		UserRepository.findOne = jest.fn().mockResolvedValue(originUserInfo);  
		UserRepository.update = jest.fn().mockResolvedValue(true);  
		
		// 지저분한 모의 코드
		// 모의하는 함수가 아닌 그 함수의 메소드를 모의함으로 코드 해석시 가독성이 떨어진다.
		// 만약 재사용되는 함수 내부의 메소드가 많으면 많을수록 불필요한 모의가 더 늘어나게 된다.
		User.findByNickname = jest.fn().mockResolvedValue(null);  
		User.findByEmail = jest.fn().mockResolvedValue(null);  
		
		bcrypt.genSalt = jest.fn().mockResolvedValue('salt');  
		bcrypt.hash = jest.fn().mockResolvedValue('hashedPassword');  
	});  
  
	afterAll(() => {  
		jest.restoreAllMocks();  
	});

	test('사용자 정보 수정 - 닉네임 변경 성공시, 닉네임 중복 여부 확인', async () => {  
		const newUserInfo: UserDto = {  
			nickname: 'newNickname',  
			email: '[email protected]',  
			password: 'newPassword',  
		};  
		
		const checkDuplicateNicknameSpy = jest.spyOn(  
			usersService,  
			'checkDuplicateNickname'  
		);  
		  
		await usersService.updateUserInfo(userId, newUserInfo);  
		  
	// 아래 라인부터 문제의 코드 ---------- spyOn으로 추적이 안된다!! 
		expect(usersService.checkDuplicateNickname).toHaveBeenCalled();  
		  
		expect(checkDuplicateNicknameSpy).toHaveBeenCalledWith(  
			newUserInfo.nickname  
		);  
	})
});

 

과정

여러번의 코드 수정을 해가며 테스트를 해본 결과,

동일 파일 내부에서의 함수를 재사용하는 상황에서 해당 함수를 모의 했을 때,
jest는 원래의 함수를 모의하고 추적하며
재사용되는 함수 자체는 기능을 할지라도 그 함수를 모의하고 추적하는 것이 아니었다.

때문에 jest.spyOn() 메소드에 대해 다시 한번 제대로 살펴보았고, 다음은 그에 대한 나의 생각을 정리 기록이다.
 

Note

jest.spyOn() 문법은 객체안의 메소드를 모의하고 추적한다.

여기서 중요한 부분은 ‘객체안의 메소드’라는 말이다.

user.service.ts 파일안의 함수들을

export default {
function1,
function2,
function3
}

 

이런 식으로 내보내기 한다는 것은,
저 문법 그대로 user.service.ts라는 객체를 만들고 그 안에 함수들을 메소드화 시킨다는 의미가 된다.
 

즉, user.service.ts 객체 안의 function1이라는 메소드가 만들어진다는 뜻.

 

jest.spyOn(객체, ‘메소드’)의 사용법대로 적용시켜보면 우리가 흔히 사용하는 다음과 같은 코드가 나온다.
jest.spyOn( userService, 'function1')
 

여기서 중요한 게 export한 메소드, 즉 그 함수만이 내보내기가 된다.
 

헷갈릴 수 있는데
function2 내부에 있는 function1
user.service.ts에서 내보내기 한 function1 함수와는 다른 존재이다.

 

function2 내부에 있는 function1의 구현이 function1 이라고 명명된 함수의 코드를 재사용하는 것일 뿐, jest.spyOn( userService, 'function1') 에서 추적하는 메소드는 function2 안에 있는 function1이 아니라 원래의 함수라는 뜻이다.
 

이를 굳이 jest.spyOn()의 문법대로 표현을 해보자면,
function2 내부에서 재사용된 function1 함수를 모의하기 위해서는
userService.function2(객체), function1(메소드)처럼 사용되어야 한다는 뜻이다.

 

현재 내가 추적하고자 하는 것은
jest.spyOn( userService, 'function1') 이 아니라,
jest.spyOn(userService.function2, function1)이다.

 

그러면 이대로 jest.spyOn(userService.function2, 'function1')으로 사용하면 될것 같지만 이건 사용할 수 없는 문법이다.
왜냐하면 userService.function2 는 객체가 아닌 메소드이기에 객체 자리에서 쓸수 없다는 문법 에러가 발생한다.

 

때문에 쉬운 방법은 function2안에 있는 function1user.service.ts안의 function1을 일치시켜주면 된다.
이렇게 하기 위해서는 객체지향 프로그래밍(OOP) 을 사용할 수 있다.

그게 아니라면 userService.function2 이 부분을 따로이 객체화만 시켜주면 된다.
이때의 가장 간단한 방법은 파일(모듈) 분리이다.

user.service2.ts라는 또다른 파일을 만들고 여기서 내보내기를 하면, jest.spyOn(userService2, 'function1') 이라는 객체안 메소드라는 문법이 완성된다.

 

해결방안

1. 객체지향 프로그래밍으로 전환

다음과 같이 user.service.ts 파일의 함수들을 OOP로 리팩토링한다.

export class UserService {
	checkDuplicateEmail = async ( email: string ) => {
		...
	}
	
	checkDuplicateNickname = async ( nickname: string ) => {
		...
	}
	
	updateUserInfo = async (userId: number, userInfo: UserDto) => {  
		...
		// 아래 라인이 동일 파일 내 재사용되는 함수 1.
		await this.checkDuplicateNickname(userInfo.nickname);  
		  
		// 아래 라인이 동일 파일 내 재사용되는 함수 2.
		await this.checkDuplicateEmail(userInfo.email);   
		  
		...
	};
};

 

이렇게 하면, 재사용 함수 역시 jest가 스코프하는 부분에서 동일한 함수명으로 사용되기 때문에 테스트 코드 작성시 아주 간단하게 해당 메소드만으로 모의가 가능해진다.
 
이는 this.checkDuplicateNickname 함수가
UserService 객체 내에 있는 원 함수인 checkDuplicateNickname 그 자체이기 때문이다.

 

2. 함수 모듈 분리

앞서 언급한 OOP 방법과 기본 원리는 같다.
재사용되는 함수를 다른 파일로 이동시킨 후, 해당 파일을 import 하여 사용하면 된다.

이때의 user.service.ts 파일 내 updateUserInfo 함수에서의 재사용함수는 validate.service.ts라는 파일 내부로 이동시켰고 이에 따라 다음과 같이 코드를 작성할 수 있다.

import validateService from './validate.service.ts'

const updateUserInfo = async (userId: number, userInfo: UserDto) => {  
	// 이전 코드...
	
	// 아래 라인이 동일 파일 내 재사용되는 함수 1. => import한 메소드로 변경
	await validateService.checkDuplicateNickname(userInfo.nickname);  
	}  
	  
	if (userInfo.email && userInfo.email !== originUserInfo.email) {  
	// 아래 라인이 동일 파일 내 재사용되는 함수 2. => import한 메소드로 변경
	await validateService.checkDuplicateEmail(userInfo.email);  
	}  
	  
	// ... 이후 코드...
	});  
};

 

그리고 테스트코드는 아래와 같이 가독성 좋게 바뀌고 성공적으로 테스팅도 보장되었다.
 

describe('updateUserInfo', () => {  
// 이전 코드...
  
beforeEach(() => {  
jest.resetAllMocks();  
  
UserRepository.findOne = jest.fn().mockResolvedValue(originUserInfo);  
UserRepository.update = jest.fn().mockResolvedValue(true);


// 간단하게 모의 끝!! 해당 함수의 메소드가 아닌 함수 그 자체를 모의함으로 유지보수시 가독성 또한 높아진다.  
validateService.checkDuplicateNickname = jest.fn();  
validateService.checkDuplicateEmail = jest.fn();  

// 이후 코드 ..
});  
  
afterAll(() => {  
jest.restoreAllMocks();  
});  
    
it('사용자 정보 수정 - 닉네임 변경 성공시, 닉네임 중복 여부 확인', async () => {  
const newUserInfo: UserDto = {  
nickname: 'newNickname',  
email: originUserInfo.email,  
};  
  
await usersService.updateUserInfo(userId, newUserInfo);  

// 테스트 성공!! 
expect(usersService.checkDuplicateNickname).toHaveBeenCalled();
expect(usersService.checkDuplicateNickname).toHaveBeenCalledWith(newUserInfo.nickname);

 

모듈분리로 인한 프로젝트 조직 변경 - 눌러서 보기

기존 조직 구성도

├── services
	├── categories.service.ts
	├── comments.service.ts
	├── feeds.service.ts
	├── search.service.ts
	├── symbol.service.ts
	├── upload.service.ts
	├── uploadFile.service.ts
	└── users.service.ts

변경된 조직 구성도

├── services
	├── categories.service.ts
	├── comments.service.ts
	├── feeds.service.ts
	├── search.service.ts
	├── symbol.service.ts
	├── upload.service.ts
	├── uploadFile.service.ts
	└── users
		├── auth.service.ts
		├── user.service.ts
		├── userContent.service.ts
		└── validator.service.ts

기존 user.service.ts 파일 내 함수들

  • checkDuplicateNickname - 재사용 함수
  • checkDuplicateEmail - 재사용 함수
  • signUp
  • signIn
  • resetPassword
  • updateUserInfo
  • deleteUser
  • findUserInfoByUserId - 재사용 함수
  • findUserFeedsByUserId - 재사용 함수
  • findUserCommentsByUserId - 재사용 함수
  • findUserFeedSymbolsByUserId

위 파일과 관련 함수를 논리적으로 그룹화하고, 도메인 중심의 디렉토리 구조를 사용하여 유사한 기능을 가진 함수를 함께 배치하여 보았다.

  1. auth.service.ts - 인증과 관련된 함수들
    • signUp
    • signIn
    • resetPassword
  2. user.service.ts - 사용자 정보 변경과 관련된 기본 함수들
    • updateUserInfo
    • deleteUser
  3. userContent.service.ts - 사용자 콘텐츠 관련 함수들
    • findUserInfoByUserId
    • findUserFeedsByUserId
    • findUserCommentsByUserId
    • findUserFeedSymbolsByUserId
  4. validator.service.ts - DB 입력시 유효성 검사와 관련된 함수들
    • checkDuplicateNickname
    • checkDuplicateEmail

이런 식으로 파일을 분리시 장점은
각 파일은 그에 해당하는 목적에만 집중하게 된다.
그리고 Jest를 사용하여 테스트 코드를 작성시, 각각의 서비스 파일이 보다 명확한 책임을 가지고 있기 때문에 테스트가 더 쉬워질 뿐더러 테스트 코드 작성 또한 매우 용이해진다.


 

이에 따라 현재 2개의 git branch를 분기하여 1번 방법인 OOP와 2번의 파일 분리를 대략적으로 진행하여 테스트를 진행해 보았고,

당연한 결과겠지만 아주 간단하게 test 코드가 작성되면서 성공적으로 테스팅이 이루어졌다.