단위테스트는 다른 코드의 일부분이 유효한지를 검사하는 코드이다.
단위테스트는 소프트웨어의 핵심이 되는 필수적인 기능으로서 일반 비즈니스 로직과 동일한 수준으로 다루어져아 한다.
단위테스트는 비즈니스 로직이 특정 조건을 보장하는지를 확인하기 위해 여러 시나리오를 검증하는 코드이다. 단위 테스트는 다음과 같은 특징을 가진다.
격리 : 단위 테스트는 다른 외부 에이전트와 완전히 독립적이어야 하며 비즈니스 로직에만 집중해야 한다.
성능 : 단위 테스트는 신속하게 실행되어야 한다. 반복적으로 여러 번 실행될 수 있도록 설계해야 한다.
자체 검증 : 단위 테스트의 실행만으로 결과를 결정할 수 있어야 한다. 단위 테스트를 처리하기 위한 추가 단계가 없어야 한다.
자동화된 테스트의 다른 형태
단위 테스트는 함수, 메서드와 같은 작은 단위를 확인하기 위한 것이다. 클래스를 테스트하려면 단위 테스트의 집합인 테스트 스위트를 사용한다.
단위테스트의 종류
통합 테스트: 한 번에 여러 컴포넌트를 테스트, 부작용, 격리를 고려하지 않은 채로 작업을 수행
인수 테스트: 유스케이스를 활용하여 사용자의 관점에서 시스템의 유효성을 검사
일반적으로 단위 테스트는 항상 수행되기를 원하지만 통합 테스트, 인수 테스트는 그보다 덜 자주 수행되기를 바란다.
단위 테스트와 애자일 소프트웨어 개발
변화에 효과적으로 대응할 수 있는 소프트웨어를 개발하고자 한다면 유연하며 확장 가능해야 한다.
코드 자체만으로는 변경에 유연하다는 보장을 할 수가 없다. 또한 잘 짜여진 컴포넌트가 변경 작업 후에도 기존 기능이 보존되며 버그가 없을것을 확신할 수는 없다.
이 경우 단위테스트가 프로그램이 명세에 따라 정확하게 동작한다는 보장을 해 줄 수 있다.
단위 테스트와 소프트웨어 디자인
테스트의 용이성은 클린 코드의 핵심 가치이다. 단위 테스트는 기본 코드를 보완하기 위한 것이 아닌 실제 코드의 작성 방식에 직접적 영향을 미치는 것이다.
class MetricsClient: """타사 지표 전송 클라이언트""" def send(self, metric_name, metric_value): if not isinstance(metric_name, str): raise TypeError("metric_name으로 문자열 타입을 사용해야 함") if not isinstance(metric_value, str): raise TypeError("metric_value으로 문자열 타입을 사용해야 함") logger.info("%s 전송 값 = %s", metric_name, metric_value)class Process: def __init__(self): self.client = MetricsClient() # 타사 지표 전송 클라이언트 def process_iterations(self, n_iterations): for i in range(n_iterations): result = self.run_process() self.client.send("iteration.{}".format(i), result)
타사 지표 전송 클라이언트는 파라미터가 문자열 타입이어야 한다는 요구사항이 있다. 따라서 run_process() 메서드에서 반환한 result가 문자열이 아닌 경우 전송에 실패한다.
타사 라이브러리는 직접 제어할 수 없으므로 반드시 실행 전에 정확한 타입을 제공해야만 한다.
단위 테스트를 통해 이 문제의 발생을 차단하려고 한다.
단위 테스트가 있으면 리팩토링을 여러 번 하더라도 문제가 이후에 재현되지 않는다는 것을 증명할 수 있다.
필요한 부분만 테스트 하기 위해 main 메서드에서 client를 직접 다루지 않고 래퍼 메서드에 위임한다.
class WrappedClient: """3rd party 라이브러리를 통제 하에 둘 수 있도록 하는 wrapper 객체""" def __init__(self): def send(self, metric_name, metric_value): return self.client.send(str(metric_name), str(metric_value))class Process: def __init__(self): self.client = WrappedClient() def process_iterations(self, n_iterations): for i in range(n_iterations): result = self.run_process() self.client.send("iteration.{}".format(i), result)
특정 상태를 가진 객체를 만들고 여러 테스트에서 해당 객체를 재사용할 수 있다. 픽스처를 정의하려면 함수를 정의하고 @pytest.fixture 데코레이터를 적용한다. 픽스처를 사용하기 원하는 테스트에는 파라미터로 픽스처의 이름을 전달하면 pytest가 그것을 활용한다.
모의(mock) 객체
Mock Object
테스트를 하는 과정 중에는 우리가 작성한 코드 뿐만 아니라 외부 서비스들과의 연결도 하게 된다. 이러한 외부 서비스에는 필연적으로 부작용이 존재한다.
모의 객체는 원하지 않는 부작용으로부터 테스트 코드를 보호하는 가장 좋은 방법 중 하나이다.
단위 테스트는 빠르게 실행되어야 하기 때문에 이러한 대기 시간을 감당할 수 없다. 따라서 단위 테스트에는 이러한 외부 서비스를 호출하지 않는다.
단위 테스트에서는 이것들이 호출되는지만 확인하면 된다.
모의 객체는 유용하지만 남용하여 안티패턴을 만들지 않도록 유의해야 한다.
패치와 모의에 대한 주의사항
단위 테스트는 보다 나은 코드를 작성하는데 도움이 된다. 특정 코드를 테스트하려면 테스트가 가능하도록 짜야 하는데, 이는 코드가 응집력이 뛰어나고 세분화 되어 있으며 작다는 것을 의미한다. 이는 코드가 응집력이 뛰어나고, 세분화 되어 있으며, 작다는 것을 의미한다. 이는 소프트웨어 컴포넌트에 있어서도 모두 좋은 특징들이다.
또한 테스트를 통해 문제가 없다고 생각하던 부분에서 나쁜 부분을 찾아낼 수 있다.
unittest 모듈은 객체를 패치하기 위한 도구를 제공한다. 패치란 임포트 중에 경로를 지정했던 원본 코드를 다른 것으로 대체하는 것을 말한다. 이렇게 하면 런타임 중에 코드가 바뀌어 테스트가 어려워지는 단점이 있다. 런타임 시 인터프리터에서 객체를 수정하는 오버헤드도 있으므로 성능상 이슈도 있다.
몽키패치, 모의 객체를 사용하는 것 자체가 문제가 되지는 않지만 몽키 패치를 남용한다면 원본 코드를 개선할 여지가 있다는 신호이다.
Mock 객체 사용하기
단위 테스트에서 말하는 테스트 더블의 카테고리에 속하는 타입에는 여러 객체가 있다. 테스트 더블은 여러가지 이유로 테스트 스위트에서 실제 코드를 대신해 실제인 것처럼 동작하는 코드를 말한다. 실제 상용 코드의 불필요, 권한 부재, 부작용 등의 이유로 사용된다.
테스트 더블에는 더미(dummy), 스텁(stub), 스파이(spy), 모의 객체(mock)와 같은 다양한 타입의 객체가 있다. 모의 객체는 가장 일반적 유형의 객체이며 융통성이 있고 다양한 기능을 가지고 있기 때문에 모든 경우에 적합하다.
모의 객체(mock)는 스펙을 따르는 객체 타입으로 응답 값을 수정할 수 있다. 즉 모의 객체 호출 시 응답해야 하는 값이나 행동을 특정할 수 있다. 모의 객체는 내부에 호출 방법을 기록하고 나중에 이 정보를 사용하여 애플리케이션의 동작을 검증한다.
Mock 객체의 종류
파이썬 표준 라이브러리는 unittest.mock 모듈에서 Mock과 MagicMock 객체를 제공한다. Mock은 모든 값을 반환하도록 설정할 수 있는 테스트 더블이며 모든 호출을 추적한다. MagicMock 은 Mock의 기능을 모두 가지고 매직 메서드 또한 지원한다. Mock 객체에서 매직 메서드를 사용하려고 하면 에러가 발생한다.
지금 테스트 하려는 것은 정보가 적절하게 구성되어 API에 잘 전달되었는지 여부이다. 따라서 실제로 API를 호출할 필요는 없고 API가 잘 호출 되는지만 확인하면 된다.
또 다른 문제는 API에 전달되는 값 중 시간 값이 있는데, 이 값은 실시간으로 변하는 값이므로 정확히 예측을 할 수가 없다는 점이다.
datetime 모듈은 C로 작성되었으므로 직접 패치할 수는 없다. 따라서 여기에서는 직접 패치할 수 있는 build_date 정적 메서드를 래핑한다.
@mock.patch를 데코레이터를 사용하여 테스트 안에서 mock_2.request를 호출하면 mock_requests라는 객체가 대신할 것이라고 알려준다. 그리고 mock.patch 함수를 컨텍스트 매니저로 사용하여 build_date() 메서드 호출 시 어설션에 사용할 build_date를 반환하도록 패치한다.
이는 mock_2.requests.post에 날짜가 포함된 데이터가 파라미터로 전달될 경우 HTTP 상태가 STATUS_ENDPOINT이 될 것이라는 지정을 한 셈이다.
따라서 mock_request.post 에 동일한 파라미터를 사용해 호출하면 assert_called_with는 성공하게 된다.
Mock 사용예제
# user_manager.pyimport requestsdef get_user(id): response = requests.get(f"https://jsonplaceholder.typicode.com/users/{id}") if response.status_code != 200: raise Exception("Failed to get a user.") return response.json()def create_user(user): response = requests.post(f"https://jsonplaceholder.typicode.com/users", data=user) if response.status_code != 201: raise Exception("Failed to create a user.") return response.json()
get_user 테스트
from unittest import TestCasefrom unittest.mock import patchimport user_managerclass TestUserManager(TestCase): @patch("requests.get") def test_get_user(self, mock_get): res = mock_get.return_value res.status_code = 200 res.json.return_value = { "name": "Test User", "email": "user@test.com", } # 내부에서 requests.get()을 호출한다. user = user_manager.get_user(1) self.assertEqual(user["name"], "Test User") self.assertEqual(user["email"], "user@test.com") # mock_get()이 정확한 파라미터로 호출되었는지 확인한다. mock_get.assert_called_once_with("<https://jsonplaceholder.typicode.com/users/1>")
리팩토링은 소프트웨어 유지 관리에서 중요한 활동이지만 단위 테스트가 없다면 정확성을 보장받기 어렵다.
일반적으로 코드를 리팩토링할 때는 구조를 개선하여 보다 나은 코드로 만들려는 경우가 있고, 떄로는 좀 더 일반적인 코드로 수정하여 가독성을 높이려는 경우가 있다. 중요한 점은 이러한 수정작업 이전과 이후가 완전히 동일한 기능을 유지해야 한다는 것이다.
이전과 같은 기능을 지원할 때에만 다른 코드를 사용할 수 있다는 것은 수정된 코드에 대해 회귀 테스트를 실행해야 함을 의미한다. 회귀 테스트를 실행하는 유일한 효율적 방법은 테스트를 자동화 하는 것이다. 자동 테스트의 가장 효율적인 버전이 바로 단위 테스트이다.
단위 테스트에 대한 추가 논의
속성 기반 테스트
속성 기반 테스트는 테스트를 실패하게 만드는 데이터를 찾는 것이다. 이를 위해 hypothesis 라이브러리를 사용한다. 이 라이브러리는 코드를 실패하게 만드는 데이터를 찾는데 도움을 준다.
이 라이브러리를 통해 성공하지 못하는 반대 사례를 찾을 수 있다. 이 라이브러리에 코드의 유효한 가설을 정의하면 hypothesis 라이브러리가 에러를 유발하는 사례를 찾아줄 것이다.
변형 테스트
단위 테스트를 작성하는 이유는 버그로부터 코드를 보호하고 서비스 중에 정말 발생해서는 안 되는 실패에 대해 미리 검증하기 위한 것이다. 검사는 통과하는 것이 좋지만 테스트를 잘못하여 통과한 것이라면 더 위험할 수 있다. 즉 단위테스트중 버그를 추가했다면 적어도 하나 이상의 테스트에서 이를 포착하여 테스트에 실패해야 한다. 만약 이런 일이 발생하지 않았다면 테스트에 누락된 부분이 있다거나 올바른 체크를 하지 않았다는 뜻이다.
이것이 변형 테스트를 하는 이유이다. 변형 테스트 도구를 사용하면 원래 코드를 변경된 새로운 코드로 수정한다. 좋은 테스트 스위트는 이러한 돌연변이를 죽여야 한다. 일부 돌연변이가 실험에서 생존하면 대개 나쁜 징후이다.
테스트 주도 개발 간략 소개
TDD의 요점은 기능의 결함으로 실패하게 될 테스트를 상용화 전에 미리 작성해야 한다는 것이다.
단위 테스트를 먼저 작성한 다음에 코드를 작성하면 기본적인 기능 테스트를 누락할 가능성이 매우 낮아진다.
이러한 작업은 크게 3단게로 구성된다.
구현 내용을 기술하는 단위 테스트를 작성한다. 여기서 테스트를 실행하면 기능이 아직 구현되지 않았기 때문에 실패할 것이다.
해당 조건을 충족시키는 최소한의 필수 코드를 구현하고 테스트를 다시 실행한다. 이번에는 테스트를 통과해야 한다.