Mock 사용해보기(Unittest)

hodu·2022년 12월 9일
0

python

목록 보기
16/17
post-thumbnail

Mock이 뭔데?

mock은 "모조품"이라는 뜻이다.
구글에 "what is pytest mock?"라고 치면 이렇게 나온다

pytest에서 mocking은 함수 내에서 함수의 반환 값을 대체할 수 있습니다.
이는 원하는 함수를 테스트하고 테스트 중인 원하는 함수 내에서 중첩 함수의 반환 값을 대체하는 데 유용합니다.(번역)

몬소리야...

아무튼 무언가 모조품을 만들고 이 모조품을 통해 실제 객체를 호출하지 않더라도 테스트가 가능하도록 만들어주는 것이다.

이해를 위해 예를 들자면 user를 만드는 create_user라는 함수가 제대로 동작하는지 확인하기 위해서는 우리는 실제 DB에 어떤 값을 보내야 할 것이다.

# 망한 코드
def test_create_user():
    response = client.post(
        "/api/user/",
        json={
            "user_id": "test_user",
            "nickname": "test_user",
            "password": "testuser1",
            "password_check": "testuser1",
        },
    )
    assert response.status_code == 201
    assert response.json() == {"detail": "사용자가 생성되었습니다."}

이런 식으로 테스트 코드를 작성해야 할 텐데, 문제가 있다.

🚨 한 번 테스트하면 다시 사용할 수 없음!

user_id 값과 nickname은 unique 한 값이다. 그래서 이 테스트코드를 한 번 돌리고 나면 중복 에러로 인해 테스트 에러가 뜨게된다.
나는 해결 방법으로 random이나 uuid를 사용해볼까 생각했었다.

그러면 이 문제를 어떻게 해결하냐?

" MOCK을 사용한다! "

mock을 사용해야 하는 또 다른 이유

Mock을 사용해야 하는 또 다른 이유는 다음과 같다.

1. 요청이 오래 걸려서..

unittest, pytest는 매우 빠르다.
그래서 sleep이나 http request를 통해 "기다려야"하는 상황에서 너무 오래 걸리게 된다면 지체없이 끝내버리고 OK를 던져버린다.

결론은 이 테스트가 진짜로 성공했는지 실패했는지 모른채로 끝나게 된다.

2. 종속성

def a():
	return 'hello world'
    
def b():
	return a

나는 b라는 함수를 테스트해보고 싶은데, a라는 함수가 무조건적으로 호출되어야 하는 상황이다. 이런 상황을 "종속성"이라고 하며, mock을 사용하면 해결이 된다.

이런 이유들 때문에 우리는 Mock을 사용하게 된다.

Mock 사용하기

그럼 이론은 어느정도 알게되었으니 본격적으로 사용해보자.

main.py에 아래와 같은 코드가 있을 때, 이 코드를 mock을 이용해서 테스트 해보자.

# main.py
import requests

class Blog:
    def __init__(self, name):
        self.name = name

    def posts(self):
        response = requests.get("https://jsonplaceholder.typicode.com/posts")

        return response.json()

    def __repr__(self):
        return '<Blog: {}>'.format(self.name)

마찬가지로 아래와 같은 테스트 코드를 작성하자.

from unittest import TestCase
from unittest.mock import patch, Mock


class TestBlog(TestCase):
    @patch('main.Blog')
    def test_blog_posts(self, MockBlog):
        blog = MockBlog()

        blog.posts.return_value = [
            {
                'userId': 1,
                'id': 1,
                'title': 'Test Title',
                'body': 'Far out in the uncharted backwaters of the unfashionable  end  of the  western  spiral  arm  of  the Galaxy\ lies a small unregarded yellow sun.'
            }
        ]

        response = blog.posts()
        self.assertIsNotNone(response)
        self.assertIsInstance(response[0], dict)
        self.assertEqual(response[0]['userId'], 1)
        self.assertEqual(response[0]['title'], 'Test Title')

데코레이터 @patch를 사용하게 되면 이 안에 들어있는 함수, 클래스의 mock(모조품)이 반환되어 데코레이터 된 함수의 인수로 전달된다.

즉, 여기서는 MockBlog라는 이름을 통해 main.Blog의 모조품이 전달받았고 이걸 blog라는 변수에다 저장을 한 것이다.

그리고 return_value를 이용하여 어떤 값이 돌아올 것인지도 지정하게 된다.


?

이걸 쓰다보면 이걸 왜 하지? 라는 의문점이 들 것이다.
(특히 이부분)

self.assertEqual(response[0]['userId'], 1)
self.assertEqual(response[0]['title'], 'Test Title')

내가 return_value를 지정해주는데, reponse를 왜 검사하지?
실제로 posts()라는 객체로 들어갔다 나오는 것도 아닌데 이걸 왜 검사할까?

다시 돌아와서

Blog 클래스에 있는 posts라는 함수는 requests를 이용한 테스트이다. 그래서 mock을 이용해주어야 한다.

(추가)우리는 위에서 "종속성"을 해결해주기 위해서 mock을 사용한다고 했다.

def a():
	return 'hello world'
    
def b():
	return a

위와 같은 코드를 테스트하기 위해서는 아래와 같이 작성하면 된다.

from main import b 

def test_mocking_function(mocker): 
    mocker.patch( "main.a" , return_value='hello world') 
    
    assert dummy_function() == 'hello world'

즉 a라는 함수가 무엇을 받아올 것인지 테스트코드에서 정의해주고, b 함수를 테스트 할 때 mock을 이용해 종속성을 해소(?)한다.

잉? 그럼 아까 그 코드는?

self.assertEqual(response[0]['userId'], 1)
self.assertEqual(response[0]['title'], 'Test Title')

응답값을 확인하는 코드이다.
응답값이 예상한대로 들어왔는지를 확인하는 코드임. 얼핏보면 내가 작성한 return_value를 왜 또 확인하나 싶겠지만 테스트의 안정성과 명확성을 위해서 작성해두는걸 권장하는 코드이다.

후기

후.. 어렵다. pytest처럼 하면 되겠지 하고 가볍게 생각했는데, 쓰다보니 왜 쓰는지도 잘 모르겠고, 어떻게 쓰는지도 정확하게 모르겠어서 이 간단한 문제를 지금 벌써 3일째 헤매는 중이다..ㅠㅠ
아직도 해결중이라 관련해서 알게되는 내용마다 추가할 예정!

추가로 알게 된 부분

# 토큰을 받아서 유저 id를 추출하는 로직
def get_user_id_from_token(token):
    try:
        decoded_payload = jwt.decode(
            token,
            config("SECRET_KEY"),
            algorithms=["HS256"],
        )

        # user_id 추출
        user_id = decoded_payload.get("user_id")
        return user_id
    except jwt.ExpiredSignatureError:
        raise ValueError("토큰이 만료되었습니다.")

    except jwt.InvalidTokenError as e:
        raise ValueError(f"유효한 토큰이 아닙니다.: {str(e)}")
# 위 로직의 테스트 코드
# 1. 유효하지 않은 토큰 테스트
@patch("jwt.decode")
def test_get_user_id_from_invalid_token(mock_decode):
    mock_decode.side_effect = jwt.InvalidTokenError("Invalid token")

    with pytest.raises(ValueError, match="유효한 토큰이 아닙니다."):
        get_user_id_from_token(invalid_token)

# 2. (성공) 유효한 토큰 테스트
@patch("jwt.decode")
def test_get_user_id_from_valid_token(mock_decode):
    mock_decode.return_value = {"user_id": 1}

    user_id = get_user_id_from_token(valid_token)
    assert user_id == 1
    mock_decode.assert_called_once_with(valid_token, ANY, algorithms=["HS256"])

위 코드는 내가 실제로 사용하고 있는 코드이며, 토큰이 유효하지 않을 경우 오류를 보내는지, 토큰이 유효할 경우 user_id가 1인지를 확인하는 코드이다.

그래서 실제 구현한 로직에서는 jwt.decode를 이용해서 받아온 토큰을 디코딩하고 user_id를 확인하게 된다.

        decoded_payload = jwt.decode(
            token,
            config("SECRET_KEY"),
            algorithms=["HS256"],
        )

(이 부분이다)

하지만 테스트코드에서는 굳이 실제 jwt를 생성하고 그 안에 user_id를 담는다는 로직 자체를 구현할 필요가 없다. 왜냐하면 jwt를 잘 decoding하고 user_id를 내보내는 것은 내가 구현한 코드의 책임이 아니라 jwt.decode의 책임이기 때문에 그렇다.

@patch("jwt.decode")
def test_get_user_id_from_valid_token(mock_decode):
    mock_decode.return_value = {"user_id": 1}

그래서 patch를 활용하여 jwt.decode를 mock으로 구현하고 리턴값도 보내준다.

테스트코드 내에서 jwt.decode를 호출할 때 테스트 메서드의 파라미터로 담긴 mock_decode 이름으로 호출되며, 이 함수는 return_value{"user_id": 1}으로 주었기 때문에 {"user_id": 1}를 리턴한다.

그럼 테스트코드에선 mock_decode가 실제로 호출이 되었는지 assert_called_once_with를 이용하여 확인하고 리턴한 값의 user_id가 1인지도 확인하게 된다.

참고 링크

profile
안녕 세계!

2개의 댓글

comment-user-thumbnail
2024년 5월 23일

혹시 더 알게된 부분 있으실까요?

1개의 답글