테스트 더블 실전: mock·fake·fixture 함정과 비용 측정
stub·fake·mock·spy를 직접 돌려 비교. 패치 대상 착각, autospec이 잡는 시그니처 변경, 정리 안 되는 fixture, 깨진 변경을 두 번 통과시킨 mock까지 실측과 함께.
시리즈: 테스트·디버깅 강좌 4/6 (중급). 평범한 단위 테스트는 쓸 수 있는데(3강), 외부 API·데이터베이스·파일을 건드리는 코드의 테스트에서 막히는 분을 위한 글입니다. 다 읽고 나면 stub, fake, mock, spy를 구별하고, 올바른 이름을 패치하고, autospec으로 시그니처 변경을 잡고, setUp이 실패해도 정리되는 fixture를 쓰고, 너무 많이 mock해서 절대 실패할 수 없게 된 테스트를 알아볼 수 있습니다.
장애 대응 회고에서 "테스트는 전부 통과했는데요"라는 말이 나왔다면, 십중팔구 어딘가에 mock이 있습니다. 신입 온보딩 때 "외부 API는 mock하면 된다"고 배웠고, 코드 리뷰에서도 통과했는데, 운영에서 깨진 경우입니다. 이 글은 그런 일이 생기는 세 가지 경로를 실제 출력으로 재현합니다. 조용히 진짜 네트워크로 나간 패치, 잘못된 인자의 호출을 받아 준 mock, 운영을 깨뜨린 리팩터링 두 번을 모두 통과시킨 리포지토리 mock입니다. 실행 환경은 Python 3.9.6(메시지는 3.12.14와도 비교)과 Dart 3.11, Apple Silicon Mac(M1 Pro)이며 경로는 ~/demo/로 줄였습니다.
숫자부터 보겠습니다. 일반 Mock 테스트는 깨진 리팩터링 두 번을 모두 통과시켰습니다. 메모리 SQLite fake는 두 번 다 잡았고, 측정한 것 중 가장 빨랐습니다(테스트당 47µs, Mock은 141µs, create_autospec은 786µs).
stub, fake, mock, spy: 네 가지 역할
무엇이든 "mock"이라고 부르기 쉽지만, 고전적인 용어(Gerard Meszaros의 『xUnit Test Patterns』, Martin Fowler의 "Mocks Aren't Stubs"로 널리 퍼진 것)는 역할을 나눕니다. 테스트 더블은 테스트하는 동안 진짜 의존 대상을 대신하는 객체를 통틀어 부르는 말입니다.
| 더블 | 하는 일 | 테스트가 확인하는 것 | 이 글의 예 |
|---|---|---|---|
| Dummy | 인자 자리만 채우고 쓰이지 않음 | 그 자체는 아무것도 | 안 쓰는 로거 자리에 None |
| Stub | 미리 정한 답을 돌려줌 | 테스트 대상의 결과 | fetch_rate가 1392.5를 돌려주도록 패치 |
| Fake | 동작하지만 단순한 구현 | 진짜 같은 동작을 거친 결과 | 운영 DB 대신 SQLite :memory: |
| Mock | 호출을 기록하고 테스트가 그걸 검증 | 올바른 호출이 일어났는지 | repo.add.assert_called_once_with("minjun") |
| Spy | 진짜를 감싸서 호출을 기록 | 진짜 결과와 호출 둘 다 | patch.object(repo, "add", wraps=repo.add) |
Python에서는 unittest.mock.Mock 클래스 하나가 stub, mock, spy 역할을 다 합니다. 이름은 클래스가 아니라 "그걸로 무엇을 하느냐"를 가리킵니다. 이게 중요한 이유는, 가장 흔한 실수가 결과만 확인하면 되는 자리(stub이나 fake로 충분한 자리)에서 호출을 확인하는 mock을 쓰는 것이기 때문입니다.
테스트할 코드
달러 청구서 금액을 원화로 바꿉니다. rates.py가 환율 서비스에 묻고, invoice.py가 그 결과를 씁니다. 이 Mac에서는 9번 포트를 아무도 듣고 있지 않으므로, 진짜 호출은 인터넷으로 나가지 않고 바로 실패합니다.
# rates.py
import json
import urllib.request
def fetch_rate(currency):
"""How many units of `currency` one USD buys (rates service)."""
url = f"http://127.0.0.1:9/rate?c={currency}" # nothing listens on port 9
with urllib.request.urlopen(url, timeout=2) as resp:
return json.load(resp)["rate"]
# invoice.py
from rates import fetch_rate
def invoice_total(amount_usd, currency):
return round(amount_usd * fetch_rate(currency))
아래의 1392.5는 테스트용으로 정한 값이고 실제 환율이 아닙니다.
패치 함정 1: 이름을 "찾는 곳"을 패치한다
# test_invoice.py
import unittest
from unittest.mock import patch
from invoice import invoice_total
class InvoiceTest(unittest.TestCase):
@patch("rates.fetch_rate", return_value=1392.5) # wrong target
def test_patched_at_definition(self, fake_rate):
self.assertEqual(invoice_total(800, "KRW"), 1114000)
@patch("invoice.fetch_rate", return_value=1392.5) # where it is looked up
def test_patched_where_used(self, fake_rate):
self.assertEqual(invoice_total(800, "KRW"), 1114000)
fake_rate.assert_called_once_with("KRW")
$ python3 -m unittest
E.
======================================================================
ERROR: test_patched_at_definition (test_invoice.InvoiceTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File ".../python3.9/urllib/request.py", line 1346, in do_open
h.request(req.get_method(), req.selector, req.data, headers,
... http.client와 socket 안의 프레임 8개 생략 ...
ConnectionRefusedError: [Errno 61] Connection refused
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File ".../python3.9/unittest/mock.py", line 1337, in patched
return func(*newargs, **newkeywargs)
File "~/demo/invoice/test_invoice.py", line 10, in test_patched_at_definition
self.assertEqual(invoice_total(800, "KRW"), 1114000)
File "~/demo/invoice/invoice.py", line 5, in invoice_total
return round(amount_usd * fetch_rate(currency))
File "~/demo/invoice/rates.py", line 8, in fetch_rate
with urllib.request.urlopen(url, timeout=2) as resp:
... urllib 안의 프레임 6개 생략 ...
urllib.error.URLError: <urlopen error [Errno 61] Connection refused>
----------------------------------------------------------------------
Ran 2 tests in 0.009s
FAILED (errors=1)
내 코드의 프레임을 위에서부터 따라가면(1강), invoice.py 5번째 줄이 rates.py 8번째 줄, 즉 진짜 함수를 불렀습니다. 패치는 동작했지만 엉뚱한 이름에 걸렸습니다. from rates import fetch_rate는 import하는 순간 함수 참조를 invoice 모듈로 복사합니다. patch("rates.fetch_rate")가 바꾸는 건 rates 쪽 속성이고, invoice는 원래 함수를 그대로 들고 있습니다. 실제로는 네트워크가 연결된 날 테스트가 몰래 진짜 서비스를 호출하거나, CI에서 타임아웃 시간만큼 멈춰 있게 됩니다.
규칙은 "쓰는 쪽 모듈의 이름을 패치한다" 입니다. 그리고 대상은 import 방식에 따라 바뀝니다. invoice.py를 import rates와 rates.fetch_rate(currency)로 바꾸자 결과가 뒤집혀서, patch("rates.fetch_rate")는 통과하고 patch("invoice.fetch_rate")는 이렇게 실패했습니다.
AttributeError: <module 'invoice' from '~/demo/alt/invoice.py'> does not have the attribute 'fetch_rate'
패치 함정 2: mock은 무엇이든 받아 주고, autospec은 그렇지 않다
이번엔 흔한 리팩터링입니다. 환율 서비스에 날짜가 필요해져서 rates.py가 def fetch_rate(currency, on_date):로 바뀌었습니다. 그런데 아무도 invoice.py를 고치지 않아서 여전히 fetch_rate(currency)를 부릅니다. 운영에서는 이렇게 됩니다.
TypeError: fetch_rate() missing 1 required positional argument: 'on_date'
이 깨진 코드에 대한 테스트 세 개:
class InvoiceTest(unittest.TestCase):
@patch("invoice.fetch_rate", return_value=1392.5)
def test_plain_mock(self, fake_rate):
self.assertEqual(invoice_total(800, "KRW"), 1114000)
@patch("invoice.fetch_rate", return_value=1392.5, autospec=True)
def test_autospec(self, fake_rate):
self.assertEqual(invoice_total(800, "KRW"), 1114000)
@patch("invoice.fetch_rate", return_value=1392.5)
def test_typo_in_assert(self, fake_rate):
invoice_total(800, "KRW")
fake_rate.called_once_with("USD") # missing "assert_": checks nothing
$ python3 -m unittest -v # Python 3.9.6
test_autospec (test_invoice.InvoiceTest) ... ERROR
test_plain_mock (test_invoice.InvoiceTest) ... ok
test_typo_in_assert (test_invoice.InvoiceTest) ... ok
======================================================================
ERROR: test_autospec (test_invoice.InvoiceTest)
----------------------------------------------------------------------
Traceback (most recent call last):
...
File "~/demo/invoice2/invoice.py", line 5, in invoice_total
return round(amount_usd * fetch_rate(currency))
File "<string>", line 2, in fetch_rate
File ".../python3.9/unittest/mock.py", line 180, in checksig
sig.bind(*args, **kwargs)
...
TypeError: missing a required argument: 'on_date'
- 일반 mock은 통과했습니다.
Mock은 어떤 인자든 받으니, 옛 시그니처가 아직 있는 척을 했습니다. autospec=True는 운영과 같은TypeError로 실패했습니다. 진짜 함수의 시그니처로 mock을 만들기 때문입니다.- 오타 테스트는 3.9에서 통과했습니다.
called_once_with(assert_없음)는 자동으로 생긴 평범한 속성이라, 불러도 아무것도 확인하지 않습니다. 심지어 다른 통화"USD"를 넣었는데도 통과했습니다. Python 3.12는 이렇게 거부합니다.
AttributeError: 'called_once_with' is not a valid assertion. Use a spec for the mock if 'called_once_with' is meant to be an attribute.. Did you mean: 'assert_called_once_with'?
(마침표 두 개는 Python 원래 메시지 그대로입니다.) 아직 3.9~3.11을 쓴다면 autospec으로 같은 안전장치를 얻을 수 있습니다. spec이 있는 mock에는 called_once_with 속성이 없기 때문입니다.
fixture: setUp, tearDown, addCleanup과 임시 파일
fixture는 테스트가 시작할 때의 정해진 상태입니다. 임시 디렉터리, 설정 파일, 사용자 두 명이 들어 있는 데이터베이스 같은 것들입니다. unittest에서는 setUp에서 만들고 tearDown에서 치웁니다. 여기에 함정이 있습니다. setUp에서 예외가 나면 tearDown은 실행되지 않습니다.
class TearDownVersion(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix=PREFIX)
self.config = os.path.join(self.tmp, "config.json")
with open(self.config, "w") as f:
json.dump({"currency": "EUR"}, f)
self.client = json.load(open(os.path.join(self.tmp, "client.json"))) # forgot to create it
def tearDown(self):
shutil.rmtree(self.tmp)
class CleanupVersion(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory(prefix=PREFIX)
self.addCleanup(tmp.cleanup) # registered before anything can fail
self.config = os.path.join(tmp.name, "config.json")
with open(self.config, "w") as f:
json.dump({"currency": "EUR"}, f)
self.client = json.load(open(os.path.join(tmp.name, "client.json")))
두 버전 모두 setUp에 같은 버그(client.json 없음)가 있습니다. 각각 5번 실행하고 시스템 임시 폴더에 남은 디렉터리를 셌습니다.
TearDownVersion: runs=5 errors=5 leftover dirs=5
CleanupVersion: runs=5 errors=5 leftover dirs=0
에러 자체는 둘 다 같았습니다.
FileNotFoundError: [Errno 2] No such file or directory: '$TMPDIR/invoice-test-9nvxeqa1/client.json'
addCleanup으로 등록한 함수는 setUp이 실패해도 등록 역순으로 실행됩니다. 자원을 만든 직후에 정리 함수를 등록하세요. 그리고 고정 경로에 쓰지 말고 tempfile.TemporaryDirectory를 쓰면, 두 테스트 실행(또는 같은 CI 러너의 두 개발자)이 파일을 공유할 일이 없습니다. 만들기 비싸고 공유해도 안전한 fixture라면 setUpClass로 클래스당 한 번만 만들 수 있지만, 공유 상태야말로 테스트 순서 의존성의 원인입니다(5강).
mock보다 fake: 메모리 SQLite
이번에는 리포지토리를 쓰는 가입 서비스입니다. 이름을 정규화해서 "MinJun"과 "minjun"을 같은 사용자로 봅니다.
# users.py
import sqlite3
class UserRepo:
def __init__(self, conn):
self.conn = conn
conn.execute("CREATE TABLE IF NOT EXISTS users (name TEXT PRIMARY KEY)")
def exists(self, name):
row = self.conn.execute("SELECT 1 FROM users WHERE name = ?", (name,)).fetchone()
return row is not None
def add(self, name):
self.conn.execute("INSERT INTO users (name) VALUES (?)", (name,))
def signup(repo, name):
key = name.strip().casefold()
if repo.exists(key):
raise ValueError(f"name already taken: {key}")
repo.add(key)
return key
signup을 테스트하는 세 가지 방법:
class SignupWithMock(unittest.TestCase):
def test_new_name_is_added(self):
repo = Mock()
repo.exists.return_value = False
signup(repo, " MinJun ")
repo.add.assert_called_once()
class SignupWithAutospec(unittest.TestCase):
def test_new_name_is_added(self):
repo = create_autospec(UserRepo, instance=True)
repo.exists.return_value = False
self.assertEqual(signup(repo, " MinJun "), "minjun")
repo.add.assert_called_once_with("minjun")
class SignupWithSqliteFake(unittest.TestCase):
def setUp(self):
conn = sqlite3.connect(":memory:")
self.addCleanup(conn.close)
self.repo = UserRepo(conn)
def test_new_name_is_added(self):
self.assertEqual(signup(self.repo, " MinJun "), "minjun")
self.assertTrue(self.repo.exists("minjun"))
def test_same_name_different_case_is_rejected(self):
signup(self.repo, "MinJun")
with self.assertRaises(ValueError):
signup(self.repo, "minjun")
올바른 코드에서는 네 개 모두 통과합니다. 차이는 누군가 코드를 바꿀 때 드러납니다.
과한 mock: 깨진 리팩터링 두 번, 초록 테스트 하나
리팩터링 1: 동료가 UserRepo.add를 insert로 이름을 바꾸고 signup 수정을 잊었습니다. 운영에서는 모든 가입에서 AttributeError가 납니다.
test_new_name_is_added (test_users.SignupWithAutospec) ... ERROR
test_new_name_is_added (test_users.SignupWithMock) ... ok
test_new_name_is_added (test_users.SignupWithSqliteFake) ... ERROR
test_same_name_different_case_is_rejected (test_users.SignupWithSqliteFake) ... ERROR
...
AttributeError: Mock object has no attribute 'add'
...
AttributeError: 'UserRepo' object has no attribute 'add'
리팩터링 2: 누군가 "대소문자는 DB가 알아서 처리한다"며 signup에서 .casefold()를 지웠습니다. 하지만 DB는 처리하지 않습니다. "MinJun"과 "minjun"이 서로 다른 계정이 됩니다.
test_new_name_is_added (test_users.SignupWithAutospec) ... FAIL
test_new_name_is_added (test_users.SignupWithMock) ... ok
test_new_name_is_added (test_users.SignupWithSqliteFake) ... FAIL
test_same_name_different_case_is_rejected (test_users.SignupWithSqliteFake) ... FAIL
...
AssertionError: 'MinJun' != 'minjun'
- MinJun
+ minjun
...
AssertionError: ValueError not raised
3강에서 본 ? 줄이 여기엔 없습니다. 두 글자(M, J)가 달라서 두 줄의 유사도가 0.67로 difflib의 기준 0.75에 못 미치면, "비슷한 줄"로 보지 않고 위치 표시를 생략하기 때문입니다("Alex"와 "alex"는 딱 0.75라 ? 줄이 나옵니다).
| 깨진 리팩터링 | 일반 Mock |
create_autospec |
SQLite fake |
|---|---|---|---|
| 메서드 이름 변경, 호출 쪽 수정 누락 | 통과 | 잡음(AttributeError) |
잡음(AttributeError) |
| 정규화 삭제 | 통과 | 잡음(반환값을 확인했기 때문) | 잡음. 중복 계정 버그까지 |
일반 mock 테스트는 실패할 수가 없습니다. 확인하는 건 "어떤 add가 어떻게든 불렸다"뿐이고, 상대는 물어보는 속성을 무엇이든 만들어 내는 객체입니다. 이것이 과한 mock(over-mocking)입니다. 테스트가 구현을 그대로 따라 적을 뿐, 사용자에게 중요한 것은 아무것도 확인하지 않습니다. autospec 테스트가 리팩터링 2를 잡은 건 반환값도 확인했기 때문이고, fake는 DB처럼 동작해서 잡았습니다. 진짜 규칙인 "대소문자만 다른 같은 이름은 거부한다"를 표현한 건 fake뿐이었습니다.
mock은 테스트에서 돌릴 수 없는 경계(네트워크, 결제 서비스, 시계, 메일 발송)에 쓰세요. 내 코드에는 진짜나 fake를 우선합니다.
spy: 진짜 동작 + 호출 기록
진짜 리포지토리를 쓰면서 호출도 보고 싶다면 감싸면 됩니다.
repo = UserRepo(sqlite3.connect(":memory:"))
with patch.object(repo, "add", wraps=repo.add) as spy:
signup(repo, "MinJun")
print(spy.call_args_list, repo.exists("minjun"))
[call('minjun')] True
행은 실제로 들어갔고, 호출도 기록됐습니다.
Dart에서는: 컴파일러가 autospec 역할
Dart에는 patch가 없으니 의존 대상을 인자로 넘깁니다. 인터페이스를 implements한 손수 만든 fake는 컴파일러가 검사해 줍니다. 로컬 pub 캐시에 mocktail도 있지만, fake에는 패키지가 필요 없습니다.
// lib/rates.dart
abstract interface class RateSource {
Future<double> rate(String currency);
}
Future<int> invoiceTotal(RateSource rates, int amountUsd, String currency) async =>
(amountUsd * await rates.rate(currency)).round();
// test/rates_test.dart
class FakeRates implements RateSource {
final Map<String, double> table;
final calls = <String>[]; // a spy: records what was asked
FakeRates(this.table);
@override
Future<double> rate(String currency) async {
calls.add(currency);
return table[currency]!;
}
}
void main() {
test('converts with the fake rate', () async {
final rates = FakeRates({'EUR': 0.92});
expect(await invoiceTotal(rates, 1000, 'EUR'), 920);
expect(rates.calls, ['EUR']);
});
}
이건 통과했습니다. 그다음 인터페이스에 같은 onDate 인자를 추가하고(rate(String currency, DateTime onDate)) 다시 dart test를 돌렸습니다.
Failed to load "test/rates_test.dart":
test/rates_test.dart:10:18: Error: The method 'FakeRates.rate' has fewer positional arguments than those of overridden method 'RateSource.rate'.
...
lib/rates.dart:6:34: Error: Too few positional arguments: 2 required, 1 given.
fake와 호출하는 쪽 모두 컴파일 시점에 깨졌습니다. Python의 autospec이 실행 시점에 흉내 내는 일을 Dart에서는 컴파일러가 합니다.
더블별 비용 실측
"DB는 느리니까 mock한다"는 판단을 자주 봅니다. 같은 가입 테스트를 네 가지로 써서 쟀습니다. N = 1,000회, 5회 반복의 중앙값(테스트 1회당), 워밍업 1회 후. Python 3.9.6, Apple Silicon Mac(M1 Pro), SQLite 3.51.0. 두 번 재서 같은 경향이었습니다.
| 더블 | Python 3.9.6 | Python 3.12.14 |
|---|---|---|
일반 Mock() |
140.9 µs | 54.3 µs |
create_autospec(UserRepo, instance=True) |
785.8 µs | 660.0 µs |
SQLite :memory: fake |
47.2 µs | 45.4 µs |
TemporaryDirectory 안의 SQLite 파일 |
1,071.2 µs | 815.4 µs |
두 버전 모두 메모리 DB가 가장 빨랐습니다. create_autospec은 메모리 안에서 끝나는 선택지 중 가장 느렸는데, 클래스를 조사해서 모든 메서드 시그니처를 복사하는 비용 때문입니다. 그래도 autospec 테스트 1,000개가 1초도 안 걸리니, 얻는 안전성에 비하면 쌉니다. 요점은 "mock이 더 빠르다"가 저절로 참이 되지는 않는다는 것입니다. 임시 디렉터리의 진짜 DB 파일이 가장 느렸고, 대부분은 디렉터리와 파일을 만들고 지우는 시간입니다.
판단표
| 상황 | 쓸 것 |
|---|---|
| 내 순수 함수 | 더블 없음 |
| 내 리포지토리·저장소 계층 | 메모리 fake(SQLite :memory:, dict 기반 클래스) |
| 외부 서비스, 결제 API, 메일 | autospec=True stub + 녹화한 응답에 대한 계약 테스트 하나 |
| "알림·메일이 정말 나갔나?" | mock. assert_called_once_with로 호출 검증 |
| 진짜를 쓰면서 호출도 보고 싶다 | spy(wraps=) |
| 시각과 난수 | 시계나 시드를 주입(5강) |
솔직한 한계와 버전 메모
- 주 실행 환경은 Python 3.9.6이고, 3.12.14는 오타 검사와 시간 표의 두 번째 열에만 썼습니다.
called_once_with같은 철자 실수를 거부하기 시작한 건 Python 3.12부터이고, 3.9에서는 이번에 조용히 받아들여졌습니다.assert나assret으로 시작하는 속성은 Python 3.5부터 이미 거부합니다(3.9에서 확인). autospec,create_autospec,wraps=는unittest.mock이 Python 3.3에서 표준 라이브러리에 들어올 때부터 있었습니다.- 환율 서비스는 닫힌 로컬 포트로 대신했습니다. 느린 진짜 서비스가 상대라면 잘못된 패치는 10ms 만에 실패하지 않고 타임아웃 시간만큼 멈춥니다.
- SQLite
:memory:는 SQLite용으로 쓴 코드의 fake로 훌륭합니다. 운영이 다른 DB라면 SQL 방언 차이(타입,ON CONFLICT, 비교의 대소문자 처리) 때문에 fake에서는 통과하고 운영에서 실패할 수 있습니다. 진짜 엔진에 대한 통합 테스트도 몇 개 남겨 두세요. 그건 이번에 돌리지 않았습니다. - Dart 3.11.5,
test 1.30.0은 로컬 pub 캐시에서 가져왔습니다.mocktail은 실행에 쓰지 않았습니다. - 마이크로초 단위 시간은 한 대의 기계에서 잰 값이고 실행마다 흔들립니다(두 번의 측정에서 최소·최대는 중앙값의 12% 이내).
연습 문제
report.py에import datetime이 있고datetime.date.today()를 부릅니다. 무엇을 패치하겠습니까? 그리고patch("datetime.date.today")가 실패하는 이유는? (직접 해 보세요.)- 리팩터링 2를 잡을 수 있도록, SQLite 없이
SignupWithMock.test_new_name_is_added를 다시 쓰세요.
정답
datetime.date는 C로 만든 내장 타입이라 속성을 바꿀 수 없습니다.patch("datetime.date.today")는 3.12에서TypeError: cannot set 'today' attribute of immutable type 'datetime.date', 3.9에서can't set attributes of built-in/extension type 'datetime.date'가 납니다. 내 모듈이 쓰는 이름인patch("report.datetime")을 패치하고mock_dt.date.today.return_value를 지정하세요. 더 좋은 방법은 시계나 날짜를 함수 인자로 넘기는 것이고, 5강에서 그렇게 합니다.- 결과와 정확한 호출을 확인합니다.
self.assertEqual(signup(repo, " MinJun "), "minjun")과repo.add.assert_called_once_with("minjun").Mock()대신create_autospec(UserRepo, instance=True)를 쓰면 리팩터링 1의 이름 변경도 잡힙니다. 그래도 fake보다는 약합니다.exists가False로 고정돼 있어서 중복 계정 버그는 잡을 수 없습니다.
다음 글
- 이전: 3강 단위 테스트 입문
- 다음: 5강 불안정한 테스트: 시간·난수·동시성
- 앞 강의: 1강 에러 메시지 읽기, 2강 print·logging·디버거
- 이후: 6강 git bisect
- 관련: Python 데코레이터와 컨텍스트 매니저(
@patch와with patch(...)의 원리), 로그로 잡아낸 AI 에이전트의 오진(초록불이 진실이 아니었던 또 다른 사례), 프로그래밍 학습 로드맵.
자주 묻는 질문
unittest.mock.patch로 함수가 바뀌지 않는 이유는?
함수가 정의된 곳의 이름을 패치했고, 함수를 찾아 쓰는 곳의 이름은 패치하지 않았기 때문입니다. invoice.py에 from rates import fetch_rate가 있으면 패치 대상은 'invoice.fetch_rate'입니다. 'rates.fetch_rate'를 패치하자 invoice 쪽 참조는 그대로였고, 테스트가 진짜 연결을 시도해 Connection refused로 실패했습니다.
autospec=True는 일반 Mock이 못 잡는 무엇을 잡나요?
진짜 시그니처와 맞지 않는 호출과 존재하지 않는 속성입니다. fetch_rate에 필수 인자 on_date가 추가된 뒤 일반 mock 테스트는 계속 통과했고 운영에서는 TypeError가 났습니다. autospec 테스트는 'missing a required argument: on_date'로 실패했습니다.
메모리 SQLite fake가 mock보다 느린가요?
이번 측정에서는 반대였습니다. Python 3.9, Apple M1 Pro에서 sqlite3 ':memory:'를 쓴 테스트는 1회당 약 47마이크로초, 일반 Mock은 약 141, create_autospec은 약 786, 임시 디렉터리의 SQLite 파일은 약 1,071마이크로초였습니다.