[{"data":1,"prerenderedAt":1052},["ShallowReactive",2],{"page-\u002Fadvanced-mocking-test-doubles-in-python\u002Fspies-fakes-and-hand-rolled-test-doubles\u002Fwriting-an-in-memory-fake-repository\u002F":3},{"id":4,"title":5,"body":6,"description":1015,"extension":1016,"meta":1017,"navigation":96,"path":1048,"seo":1049,"stem":1050,"__hash__":1051},"content\u002Fadvanced-mocking-test-doubles-in-python\u002Fspies-fakes-and-hand-rolled-test-doubles\u002Fwriting-an-in-memory-fake-repository\u002Findex.md","Writing an In-Memory Fake Repository",{"type":7,"value":8,"toc":1004},"minimark",[9,30,33,38,64,68,319,428,432,439,442,446,495,499,502,689,692,787,791,794,837,844,862,930,934,937,940,944,950,956,962,966,995,1000],[10,11,12,13,17,18,21,22,25,26,29],"p",{},"A repository is the most mocked collaborator in most codebases, and the one where mocks do the most damage. A ",[14,15,16],"code",{},"Mock()"," standing in for ",[14,19,20],{},"repo.get"," returns whatever the test configured, never raises ",[14,23,24],{},"NotFound",", and never notices that the code called ",[14,27,28],{},"save"," with an object missing a required field. An in-memory fake that behaves like the real repository — storing, retrieving, rejecting duplicates, ordering results — lets business-logic tests run in microseconds while still exercising the interaction with storage honestly.",[10,31,32],{},"Writing one takes an afternoon for a typical repository, and the investment is recovered the first week: every test that previously configured three or four mock return values to simulate storage becomes a test that adds a couple of objects to the fake and asserts on what it contains afterwards. The work is in the details that make the fake trustworthy: storing copies so mutation does not leak, reproducing the error cases the real implementation raises, matching its ordering, and proving all of that with a contract suite that runs against both.",[34,35,37],"h2",{"id":36},"prerequisites","Prerequisites",[39,40,41,49,55],"ul",{},[42,43,44,45,48],"li",{},"A repository interface expressed as a ",[14,46,47],{},"typing.Protocol"," or an abstract base class.",[42,50,51,54],{},[14,52,53],{},"pytest >= 8.0",", plus the real repository available in an integration stage for the contract suite's other half.",[42,56,57,58,63],{},"The reasoning behind fakes over mocks, in ",[59,60,62],"a",{"href":61},"\u002Fadvanced-mocking-test-doubles-in-python\u002Fspies-fakes-and-hand-rolled-test-doubles\u002F","spies, fakes and hand-rolled test doubles",".",[34,65,67],{"id":66},"solution","Solution",[69,70,75],"pre",{"className":71,"code":72,"language":73,"meta":74,"style":74},"language-python shiki shiki-themes github-light github-dark","import copy\nfrom datetime import datetime\n\nfrom myapp.errors import ConcurrentUpdate, DuplicateOrder, OrderNotFound\nfrom myapp.models import Order\n\n\nclass FakeOrderRepository:\n    \"\"\"In-memory OrderRepository with the same observable behaviour as the SQL one.\"\"\"\n\n    def __init__(self) -> None:\n        self._rows: dict[str, Order] = {}\n\n    def get(self, order_id: str) -> Order:\n        try:\n            # A copy: callers mutating the result must not change stored state.\n            return copy.deepcopy(self._rows[order_id])\n        except KeyError:\n            raise OrderNotFound(order_id) from None\n\n    def add(self, order: Order) -> None:\n        if order.id in self._rows:\n            raise DuplicateOrder(order.id)          # the real unique constraint\n        self._rows[order.id] = copy.deepcopy(order)\n\n    def update(self, order: Order) -> None:\n        current = self._rows.get(order.id)\n        if current is None:\n            raise OrderNotFound(order.id)\n        if current.version != order.version:       # optimistic locking, as in SQL\n            raise ConcurrentUpdate(order.id)\n        stored = copy.deepcopy(order)\n        stored.version += 1\n        self._rows[order.id] = stored\n\n    def list_open(self, *, customer_id: str) -> list[Order]:\n        rows = [o for o in self._rows.values()\n                if o.customer_id == customer_id and o.status == \"open\"]\n        # Same ORDER BY as the SQL implementation: newest first, then id.\n        return [copy.deepcopy(o) for o in\n                sorted(rows, key=lambda o: (-o.created_at.timestamp(), o.id))]\n","python","",[14,76,77,85,91,98,104,110,115,120,126,132,137,143,149,154,160,166,172,178,184,190,195,201,207,213,219,224,230,236,242,248,254,260,266,272,278,283,289,295,301,307,313],{"__ignoreMap":74},[78,79,82],"span",{"class":80,"line":81},"line",1,[78,83,84],{},"import copy\n",[78,86,88],{"class":80,"line":87},2,[78,89,90],{},"from datetime import datetime\n",[78,92,94],{"class":80,"line":93},3,[78,95,97],{"emptyLinePlaceholder":96},true,"\n",[78,99,101],{"class":80,"line":100},4,[78,102,103],{},"from myapp.errors import ConcurrentUpdate, DuplicateOrder, OrderNotFound\n",[78,105,107],{"class":80,"line":106},5,[78,108,109],{},"from myapp.models import Order\n",[78,111,113],{"class":80,"line":112},6,[78,114,97],{"emptyLinePlaceholder":96},[78,116,118],{"class":80,"line":117},7,[78,119,97],{"emptyLinePlaceholder":96},[78,121,123],{"class":80,"line":122},8,[78,124,125],{},"class FakeOrderRepository:\n",[78,127,129],{"class":80,"line":128},9,[78,130,131],{},"    \"\"\"In-memory OrderRepository with the same observable behaviour as the SQL one.\"\"\"\n",[78,133,135],{"class":80,"line":134},10,[78,136,97],{"emptyLinePlaceholder":96},[78,138,140],{"class":80,"line":139},11,[78,141,142],{},"    def __init__(self) -> None:\n",[78,144,146],{"class":80,"line":145},12,[78,147,148],{},"        self._rows: dict[str, Order] = {}\n",[78,150,152],{"class":80,"line":151},13,[78,153,97],{"emptyLinePlaceholder":96},[78,155,157],{"class":80,"line":156},14,[78,158,159],{},"    def get(self, order_id: str) -> Order:\n",[78,161,163],{"class":80,"line":162},15,[78,164,165],{},"        try:\n",[78,167,169],{"class":80,"line":168},16,[78,170,171],{},"            # A copy: callers mutating the result must not change stored state.\n",[78,173,175],{"class":80,"line":174},17,[78,176,177],{},"            return copy.deepcopy(self._rows[order_id])\n",[78,179,181],{"class":80,"line":180},18,[78,182,183],{},"        except KeyError:\n",[78,185,187],{"class":80,"line":186},19,[78,188,189],{},"            raise OrderNotFound(order_id) from None\n",[78,191,193],{"class":80,"line":192},20,[78,194,97],{"emptyLinePlaceholder":96},[78,196,198],{"class":80,"line":197},21,[78,199,200],{},"    def add(self, order: Order) -> None:\n",[78,202,204],{"class":80,"line":203},22,[78,205,206],{},"        if order.id in self._rows:\n",[78,208,210],{"class":80,"line":209},23,[78,211,212],{},"            raise DuplicateOrder(order.id)          # the real unique constraint\n",[78,214,216],{"class":80,"line":215},24,[78,217,218],{},"        self._rows[order.id] = copy.deepcopy(order)\n",[78,220,222],{"class":80,"line":221},25,[78,223,97],{"emptyLinePlaceholder":96},[78,225,227],{"class":80,"line":226},26,[78,228,229],{},"    def update(self, order: Order) -> None:\n",[78,231,233],{"class":80,"line":232},27,[78,234,235],{},"        current = self._rows.get(order.id)\n",[78,237,239],{"class":80,"line":238},28,[78,240,241],{},"        if current is None:\n",[78,243,245],{"class":80,"line":244},29,[78,246,247],{},"            raise OrderNotFound(order.id)\n",[78,249,251],{"class":80,"line":250},30,[78,252,253],{},"        if current.version != order.version:       # optimistic locking, as in SQL\n",[78,255,257],{"class":80,"line":256},31,[78,258,259],{},"            raise ConcurrentUpdate(order.id)\n",[78,261,263],{"class":80,"line":262},32,[78,264,265],{},"        stored = copy.deepcopy(order)\n",[78,267,269],{"class":80,"line":268},33,[78,270,271],{},"        stored.version += 1\n",[78,273,275],{"class":80,"line":274},34,[78,276,277],{},"        self._rows[order.id] = stored\n",[78,279,281],{"class":80,"line":280},35,[78,282,97],{"emptyLinePlaceholder":96},[78,284,286],{"class":80,"line":285},36,[78,287,288],{},"    def list_open(self, *, customer_id: str) -> list[Order]:\n",[78,290,292],{"class":80,"line":291},37,[78,293,294],{},"        rows = [o for o in self._rows.values()\n",[78,296,298],{"class":80,"line":297},38,[78,299,300],{},"                if o.customer_id == customer_id and o.status == \"open\"]\n",[78,302,304],{"class":80,"line":303},39,[78,305,306],{},"        # Same ORDER BY as the SQL implementation: newest first, then id.\n",[78,308,310],{"class":80,"line":309},40,[78,311,312],{},"        return [copy.deepcopy(o) for o in\n",[78,314,316],{"class":80,"line":315},41,[78,317,318],{},"                sorted(rows, key=lambda o: (-o.created_at.timestamp(), o.id))]\n",[320,321,324,424],"figure",{"className":322},[323],"diagram",[325,326,333,334,333,338,333,342,333,350,333,360,333,370,333,376,333,382,333,386,333,390,333,394,333,398,333,404,333,408,333,412,333,415,333,418,333,421],"svg",{"viewBox":327,"role":328,"ariaLabelledBy":329,"xmlns":332},"0 0 820 262","img",[330,331],"fr-t","fr-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[335,336,337],"title",{"id":330},"What the fake must reproduce, and what it may omit",[339,340,341],"desc",{"id":331},"Two columns. The fake must reproduce copy semantics, the not-found, duplicate and concurrent-update errors, optimistic version increments and result ordering, because the code under test depends on all of them. It may omit transactions, connection pooling, SQL and indexes, which are properties of the storage engine rather than of the repository's contract.",[343,344],"rect",{"x":345,"y":345,"width":346,"height":347,"rx":348,"fill":349},"0","820","262","14","#fffdf8",[351,352,359],"text",{"x":353,"y":354,"textAnchor":355,"fontSize":356,"fontWeight":357,"fill":358},"410","28","middle","16","700","#3d405b","Reproduce the contract, not the engine",[343,361],{"x":362,"y":363,"width":364,"height":365,"rx":366,"fill":367,"stroke":368,"strokeWidth":369},"26","52","368","190","12","#e6f0ea","#81b29a","2",[351,371,375],{"x":372,"y":373,"textAnchor":355,"fontSize":374,"fontWeight":357,"fill":358},"210","78","12.5","must reproduce",[351,377,381],{"x":378,"y":379,"fontSize":380,"fill":358},"44","106","11","• copy semantics on save and load",[351,383,385],{"x":378,"y":384,"fontSize":380,"fill":358},"128","• OrderNotFound, DuplicateOrder",[351,387,389],{"x":378,"y":388,"fontSize":380,"fill":358},"150","• ConcurrentUpdate + version bump",[351,391,393],{"x":378,"y":392,"fontSize":380,"fill":358},"172","• result ordering",[351,395,397],{"x":378,"y":372,"fontSize":380,"fill":396},"#2a5f49","the code under test depends on these",[343,399],{"x":400,"y":363,"width":364,"height":365,"rx":366,"fill":401,"stroke":402,"strokeWidth":403},"426","#f4f1de","rgba(61,64,91,0.35)","1.6",[351,405,407],{"x":406,"y":373,"textAnchor":355,"fontSize":374,"fontWeight":357,"fill":358},"610","may omit",[351,409,411],{"x":410,"y":379,"fontSize":380,"fill":358},"444","• transactions and isolation",[351,413,414],{"x":410,"y":384,"fontSize":380,"fill":358},"• connection pooling",[351,416,417],{"x":410,"y":388,"fontSize":380,"fill":358},"• SQL, indexes, query plans",[351,419,420],{"x":410,"y":392,"fontSize":380,"fill":358},"• migrations",[351,422,423],{"x":410,"y":372,"fontSize":380,"fill":358},"tested against the real engine instead",[425,426,427],"figcaption",{},"The left column is what makes the fake safe to use. Leaving any of it out means tests pass against behaviour production does not have.",[34,429,431],{"id":430},"why-this-works","Why this works",[10,433,434,435,438],{},"A repository's contract is small: a handful of methods, the exceptions they raise, and the guarantees about what comes back. A dictionary keyed by identifier reproduces the storage, and a few explicit checks reproduce the constraints the database would enforce. Because the fake implements the same ",[14,436,437],{},"Protocol",", the type checker verifies that its methods and signatures match, and the code under test cannot tell which implementation it received. That indistinguishability is the whole goal: the code under test should exercise its real logic, including its error handling, with the only difference being where the data lives.",[10,440,441],{},"Deep copies are the detail that closes the most dangerous gap. With references, a test that saves an order, mutates it, and then loads it would see the mutation — and conclude that some code path persisted the change when in reality nothing did. The real repository returns fresh objects every time; the fake must too.",[34,443,445],{"id":444},"edge-cases-and-failure-modes","Edge cases and failure modes",[39,447,448,455,465,475,489],{},[42,449,450,454],{},[451,452,453],"strong",{},"Storing references."," Mutations after save appear persisted. Deep-copy on both write and read.",[42,456,457,460,461,464],{},[451,458,459],{},"Missing error cases."," A fake that never raises ",[14,462,463],{},"DuplicateOrder"," means the duplicate-handling path is never exercised. Every exception the real implementation raises belongs in the fake.",[42,466,467,470,471,474],{},[451,468,469],{},"Different ordering."," Dictionary insertion order is not the SQL ",[14,472,473],{},"ORDER BY",". Tests that pass against the fake's order fail against the real one. Sort explicitly, matching the query.",[42,476,477,480,481,484,485,488],{},[451,478,479],{},"Fake-only helpers used by production code."," A ",[14,482,483],{},"clear()"," or ",[14,486,487],{},"all()"," convenience added for tests must never be called by the code under test. Keep test helpers visibly separate.",[42,490,491,494],{},[451,492,493],{},"Overbuilding."," A fake that grows query filters, pagination cursors and full-text search is reimplementing the database. Move those tests to the integration layer.",[34,496,498],{"id":497},"the-contract-suite-that-keeps-it-honest","The contract suite that keeps it honest",[10,500,501],{},"The fake is only as good as its agreement with the real repository, and the only reliable way to maintain that agreement is to run the same behavioural tests against both.",[69,503,505],{"className":71,"code":504,"language":73,"meta":74,"style":74},"import pytest\n\n\nclass OrderRepositoryContract:\n    \"\"\"Every implementation must pass these.\"\"\"\n\n    def test_get_missing_raises(self, repo):\n        with pytest.raises(OrderNotFound):\n            repo.get(\"missing\")\n\n    def test_duplicate_add_raises(self, repo, an_order):\n        repo.add(an_order)\n        with pytest.raises(DuplicateOrder):\n            repo.add(an_order)\n\n    def test_stale_update_raises(self, repo, an_order):\n        repo.add(an_order)\n        first, second = repo.get(an_order.id), repo.get(an_order.id)\n        repo.update(first)\n        with pytest.raises(ConcurrentUpdate):\n            repo.update(second)                  # stale version\n\n    def test_mutation_after_add_is_not_persisted(self, repo, an_order):\n        repo.add(an_order)\n        an_order.status = \"cancelled\"\n        assert repo.get(an_order.id).status == \"open\"\n\n\nclass TestFakeOrderRepository(OrderRepositoryContract):\n    @pytest.fixture\n    def repo(self):\n        return FakeOrderRepository()\n\n\n@pytest.mark.integration\nclass TestSqlOrderRepository(OrderRepositoryContract):\n    @pytest.fixture\n    def repo(self, db_session):\n        return SqlOrderRepository(db_session)\n",[14,506,507,512,516,520,525,530,534,539,544,549,553,558,563,568,573,577,582,586,591,596,601,606,610,615,619,624,629,633,637,642,647,652,657,661,665,670,675,679,684],{"__ignoreMap":74},[78,508,509],{"class":80,"line":81},[78,510,511],{},"import pytest\n",[78,513,514],{"class":80,"line":87},[78,515,97],{"emptyLinePlaceholder":96},[78,517,518],{"class":80,"line":93},[78,519,97],{"emptyLinePlaceholder":96},[78,521,522],{"class":80,"line":100},[78,523,524],{},"class OrderRepositoryContract:\n",[78,526,527],{"class":80,"line":106},[78,528,529],{},"    \"\"\"Every implementation must pass these.\"\"\"\n",[78,531,532],{"class":80,"line":112},[78,533,97],{"emptyLinePlaceholder":96},[78,535,536],{"class":80,"line":117},[78,537,538],{},"    def test_get_missing_raises(self, repo):\n",[78,540,541],{"class":80,"line":122},[78,542,543],{},"        with pytest.raises(OrderNotFound):\n",[78,545,546],{"class":80,"line":128},[78,547,548],{},"            repo.get(\"missing\")\n",[78,550,551],{"class":80,"line":134},[78,552,97],{"emptyLinePlaceholder":96},[78,554,555],{"class":80,"line":139},[78,556,557],{},"    def test_duplicate_add_raises(self, repo, an_order):\n",[78,559,560],{"class":80,"line":145},[78,561,562],{},"        repo.add(an_order)\n",[78,564,565],{"class":80,"line":151},[78,566,567],{},"        with pytest.raises(DuplicateOrder):\n",[78,569,570],{"class":80,"line":156},[78,571,572],{},"            repo.add(an_order)\n",[78,574,575],{"class":80,"line":162},[78,576,97],{"emptyLinePlaceholder":96},[78,578,579],{"class":80,"line":168},[78,580,581],{},"    def test_stale_update_raises(self, repo, an_order):\n",[78,583,584],{"class":80,"line":174},[78,585,562],{},[78,587,588],{"class":80,"line":180},[78,589,590],{},"        first, second = repo.get(an_order.id), repo.get(an_order.id)\n",[78,592,593],{"class":80,"line":186},[78,594,595],{},"        repo.update(first)\n",[78,597,598],{"class":80,"line":192},[78,599,600],{},"        with pytest.raises(ConcurrentUpdate):\n",[78,602,603],{"class":80,"line":197},[78,604,605],{},"            repo.update(second)                  # stale version\n",[78,607,608],{"class":80,"line":203},[78,609,97],{"emptyLinePlaceholder":96},[78,611,612],{"class":80,"line":209},[78,613,614],{},"    def test_mutation_after_add_is_not_persisted(self, repo, an_order):\n",[78,616,617],{"class":80,"line":215},[78,618,562],{},[78,620,621],{"class":80,"line":221},[78,622,623],{},"        an_order.status = \"cancelled\"\n",[78,625,626],{"class":80,"line":226},[78,627,628],{},"        assert repo.get(an_order.id).status == \"open\"\n",[78,630,631],{"class":80,"line":232},[78,632,97],{"emptyLinePlaceholder":96},[78,634,635],{"class":80,"line":238},[78,636,97],{"emptyLinePlaceholder":96},[78,638,639],{"class":80,"line":244},[78,640,641],{},"class TestFakeOrderRepository(OrderRepositoryContract):\n",[78,643,644],{"class":80,"line":250},[78,645,646],{},"    @pytest.fixture\n",[78,648,649],{"class":80,"line":256},[78,650,651],{},"    def repo(self):\n",[78,653,654],{"class":80,"line":262},[78,655,656],{},"        return FakeOrderRepository()\n",[78,658,659],{"class":80,"line":268},[78,660,97],{"emptyLinePlaceholder":96},[78,662,663],{"class":80,"line":274},[78,664,97],{"emptyLinePlaceholder":96},[78,666,667],{"class":80,"line":280},[78,668,669],{},"@pytest.mark.integration\n",[78,671,672],{"class":80,"line":285},[78,673,674],{},"class TestSqlOrderRepository(OrderRepositoryContract):\n",[78,676,677],{"class":80,"line":291},[78,678,646],{},[78,680,681],{"class":80,"line":297},[78,682,683],{},"    def repo(self, db_session):\n",[78,685,686],{"class":80,"line":303},[78,687,688],{},"        return SqlOrderRepository(db_session)\n",[10,690,691],{},"The last test is worth singling out. It passes trivially against the real repository and fails against a fake that stores references, which makes it the single most valuable assertion in the suite: it catches the mistake that makes fake-backed tests lie.",[320,693,695,784],{"className":694},[323],[325,696,333,701,333,704,333,707,333,724,333,728,333,733,333,741,333,745,333,750,333,758,333,762,333,766,333,770,333,774,333,777,333,781],{"viewBox":697,"role":328,"ariaLabelledBy":698,"xmlns":332},"0 0 800 236",[699,700],"cs-t","cs-d",[335,702,703],{"id":699},"One contract suite, two implementations",[339,705,706],{"id":700},"A single contract class of behavioural tests is inherited by two concrete test classes, one providing the in-memory fake and one providing the SQL repository. The fake half runs in every test run in milliseconds; the SQL half runs in the integration stage. A divergence fails one half and not the other, naming the behaviour that differs.",[708,709,710,711,333],"defs",{},"\n    ",[712,713,720],"marker",{"id":714,"viewBox":715,"refX":716,"refY":717,"markerWidth":718,"markerHeight":718,"orient":719},"cs-a","0 0 10 10","9","5","7","auto-start-reverse",[721,722],"path",{"d":723,"fill":368},"M0 0 L10 5 L0 10 z",[343,725],{"x":345,"y":345,"width":726,"height":727,"rx":348,"fill":349},"800","236",[351,729,732],{"x":730,"y":354,"textAnchor":355,"fontSize":731,"fontWeight":357,"fill":358},"400","15.5","Written once, run against both",[343,734],{"x":735,"y":736,"width":737,"height":738,"rx":380,"fill":739,"stroke":740,"strokeWidth":369},"280","50","240","60","#f7f0da","#f2cc8f",[351,742,744],{"x":730,"y":743,"textAnchor":355,"fontSize":366,"fontWeight":357,"fill":358},"76","OrderRepositoryContract",[351,746,749],{"x":730,"y":747,"textAnchor":355,"fontSize":380,"fill":748},"96","#8a5a00","behaviour, not implementation",[80,751],{"x1":752,"y1":753,"x2":754,"y2":755,"stroke":368,"strokeWidth":756,"markerEnd":757},"340","114","220","146","1.8","url(#cs-a)",[80,759],{"x1":760,"y1":753,"x2":761,"y2":755,"stroke":368,"strokeWidth":756,"markerEnd":757},"460","580",[343,763],{"x":738,"y":388,"width":764,"height":765,"rx":380,"fill":367,"stroke":368,"strokeWidth":369},"300","64",[351,767,769],{"x":372,"y":768,"textAnchor":355,"fontSize":366,"fontWeight":357,"fill":358},"176","fake half",[351,771,773],{"x":372,"y":772,"textAnchor":355,"fontSize":380,"fill":396},"196","every run · milliseconds",[343,775],{"x":776,"y":388,"width":764,"height":765,"rx":380,"fill":401,"stroke":358,"strokeWidth":403},"440",[351,778,780],{"x":779,"y":768,"textAnchor":355,"fontSize":366,"fontWeight":357,"fill":358},"590","SQL half",[351,782,783],{"x":779,"y":772,"textAnchor":355,"fontSize":380,"fill":358},"integration stage · seconds",[425,785,786],{},"When the two halves disagree, the failing test names the exact behaviour, which is far easier to fix than a production incident that reveals the same divergence.",[34,788,790],{"id":789},"using-the-fake-in-business-logic-tests","Using the fake in business-logic tests",[10,792,793],{},"With a trustworthy fake, the tests for everything above the repository change character. They stop configuring return values and start describing situations, and the assertions move from \"which methods were called\" to \"what state resulted\".",[69,795,797],{"className":71,"code":796,"language":73,"meta":74,"style":74},"def test_cancelling_an_order_releases_its_stock(order_service, repo, stock):\n    repo.add(an_order(id=\"ord_1\", status=\"open\", lines=[a_line(sku=\"SKU-1\", quantity=2)]))\n    stock.reserve(\"SKU-1\", 2)\n\n    order_service.cancel(\"ord_1\")\n\n    assert repo.get(\"ord_1\").status == \"cancelled\"\n    assert stock.reserved(\"SKU-1\") == 0\n",[14,798,799,804,809,814,818,823,827,832],{"__ignoreMap":74},[78,800,801],{"class":80,"line":81},[78,802,803],{},"def test_cancelling_an_order_releases_its_stock(order_service, repo, stock):\n",[78,805,806],{"class":80,"line":87},[78,807,808],{},"    repo.add(an_order(id=\"ord_1\", status=\"open\", lines=[a_line(sku=\"SKU-1\", quantity=2)]))\n",[78,810,811],{"class":80,"line":93},[78,812,813],{},"    stock.reserve(\"SKU-1\", 2)\n",[78,815,816],{"class":80,"line":100},[78,817,97],{"emptyLinePlaceholder":96},[78,819,820],{"class":80,"line":106},[78,821,822],{},"    order_service.cancel(\"ord_1\")\n",[78,824,825],{"class":80,"line":112},[78,826,97],{"emptyLinePlaceholder":96},[78,828,829],{"class":80,"line":117},[78,830,831],{},"    assert repo.get(\"ord_1\").status == \"cancelled\"\n",[78,833,834],{"class":80,"line":122},[78,835,836],{},"    assert stock.reserved(\"SKU-1\") == 0\n",[10,838,839,840,843],{},"The arrangement reads as a description of the world before the operation; the assertions read as a description of the world after it. Nothing in the test depends on how ",[14,841,842],{},"cancel"," talks to the repository — whether it loads then updates, uses a dedicated method, or batches — so the implementation can change freely as long as the outcome stays the same. That is the practical payoff of fakes over mocks, and it compounds: a service with fifty tests written this way can be restructured without touching any of them.",[10,845,846,847,849,850,853,854,857,858,861],{},"It also makes a class of bug visible that mock-based tests hide completely. If ",[14,848,842],{}," forgot to save the updated order, a mock-based test that asserted ",[14,851,852],{},"repo.update.assert_called_once()"," would fail only if the author thought to check it; the fake-based test fails because ",[14,855,856],{},"repo.get(\"ord_1\").status"," is still ",[14,859,860],{},"\"open\"",". The state assertion catches omissions without anyone having to anticipate them. That property alone justifies the fake for any repository used by more than a handful of tests.",[320,863,865,927],{"className":864},[323],[325,866,333,870,333,873,333,876,333,878,333,881,333,887,333,891,333,895,333,899,333,903,333,907,333,910,333,914,333,918,333,921,333,924],{"viewBox":697,"role":328,"ariaLabelledBy":867,"xmlns":332},[868,869],"st2-t","st2-d",[335,871,872],{"id":868},"Mock-based versus fake-based assertions",[339,874,875],{"id":869},"Two versions of the same test. The mock-based version asserts that particular repository methods were called with particular arguments and breaks when the implementation reorganises its calls. The fake-based version asserts on the resulting stored state and passes for any implementation that produces the right outcome, while failing if the save was forgotten.",[343,877],{"x":345,"y":345,"width":726,"height":727,"rx":348,"fill":349},[351,879,880],{"x":730,"y":354,"textAnchor":355,"fontSize":731,"fontWeight":357,"fill":358},"Assert on the world, not on the conversation",[343,882],{"x":362,"y":736,"width":883,"height":884,"rx":366,"fill":885,"stroke":886,"strokeWidth":369},"360","164","#fbe9e3","#e07a5f",[351,888,890],{"x":889,"y":743,"textAnchor":355,"fontSize":374,"fontWeight":357,"fill":358},"206","mock-based",[351,892,894],{"x":378,"y":893,"fontSize":380,"fill":358},"104","repo.update.assert_called_once_with(…)",[351,896,898],{"x":378,"y":897,"fontSize":380,"fill":358},"126","coupled to the call sequence",[351,900,902],{"x":378,"y":884,"fontSize":380,"fontWeight":357,"fill":901},"#8f3d22","breaks on harmless refactors",[351,904,906],{"x":378,"y":905,"fontSize":380,"fill":358},"186","misses omissions nobody checked",[343,908],{"x":909,"y":736,"width":883,"height":884,"rx":366,"fill":367,"stroke":368,"strokeWidth":369},"414",[351,911,913],{"x":912,"y":743,"textAnchor":355,"fontSize":374,"fontWeight":357,"fill":358},"594","fake-based",[351,915,917],{"x":916,"y":893,"fontSize":380,"fill":358},"432","repo.get(\"ord_1\").status == \"cancelled\"",[351,919,920],{"x":916,"y":897,"fontSize":380,"fill":358},"coupled only to the outcome",[351,922,923],{"x":916,"y":884,"fontSize":380,"fontWeight":357,"fill":396},"survives refactors",[351,925,926],{"x":916,"y":905,"fontSize":380,"fill":358},"fails if the save was forgotten",[425,928,929],{},"The right-hand test is both less brittle and more thorough — an unusual combination, and the main reason fakes are worth the effort of writing.",[34,931,933],{"id":932},"where-the-fake-should-live","Where the fake should live",[10,935,936],{},"A fake used by one module's tests can live beside them. A fake used across the codebase — and a repository fake almost always is — belongs next to the real implementation, in the application package, exported alongside it. That placement has three effects worth wanting. The fake is found by anyone looking at the repository code. It is reviewed in the same pull requests that change the real implementation, so drift is visible in the diff. And other packages that depend on this one can import the fake for their own tests rather than writing inferior copies.",[10,938,939],{},"The contract suite lives with it for the same reason. When someone adds a method to the real repository, the natural place to add its contract test is right there, and the fake half of that test fails immediately until the fake gains the same method — which is exactly the moment the author has all the context needed to implement it correctly. Deferring that work to whoever next needs the fake method means implementing it without that context, which is how fakes acquire subtly wrong behaviour in the first place.",[34,941,943],{"id":942},"frequently-asked-questions","Frequently Asked Questions",[10,945,946,949],{},[451,947,948],{},"Should the fake store objects or copies of them?","\nCopies. The real repository returns fresh objects from the database, so mutating one after saving does not change what is stored. A fake that stores references lets tests pass because a later mutation \"saved\" itself, which the real repository would never do.",[10,951,952,955],{},[451,953,954],{},"How much of the real repository's behaviour should the fake reproduce?","\nEverything the code under test depends on, including the error cases — not found, duplicate key, optimistic-lock conflict — and ordering guarantees. It does not need transactions, connection handling or query planning. The contract suite defines exactly where the line is.",[10,957,958,961],{},[451,959,960],{},"What stops the fake drifting from the real implementation?","\nA shared contract test suite that runs against both. Any behaviour the code relies on is asserted once, and both implementations must pass. The real half runs in the integration stage; the fake half runs everywhere.",[34,963,965],{"id":964},"related","Related",[39,967,968,974,981,988],{},[42,969,970,973],{},[59,971,972],{"href":61},"Spies, Fakes & Hand-Rolled Test Doubles"," — when a fake beats a mock.",[42,975,976,980],{},[59,977,979],{"href":978},"\u002Fadvanced-mocking-test-doubles-in-python\u002Fdependency-injection-for-testability\u002Finjecting-fakes-vs-mocks-in-constructors\u002F","Injecting Fakes vs Mocks in Constructors"," — getting the fake into the code under test.",[42,982,983,987],{},[59,984,986],{"href":985},"\u002Fintegration-database-and-service-testing\u002Fdatabase-fixtures-and-transactional-tests\u002F","Database Fixtures & Transactional Tests"," — running the SQL half of the contract suite.",[42,989,990,994],{},[59,991,993],{"href":992},"\u002Fadvanced-pytest-architecture-configuration\u002Fmastering-pytest-fixtures\u002Fparametrizing-fixtures-with-params-and-ids\u002F","Parametrizing Fixtures with params and ids"," — the alternative way to run one suite over both.",[10,996,997,998],{},"← Back to ",[59,999,972],{"href":61},[1001,1002,1003],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":74,"searchDepth":87,"depth":87,"links":1005},[1006,1007,1008,1009,1010,1011,1012,1013,1014],{"id":36,"depth":87,"text":37},{"id":66,"depth":87,"text":67},{"id":430,"depth":87,"text":431},{"id":444,"depth":87,"text":445},{"id":497,"depth":87,"text":498},{"id":789,"depth":87,"text":790},{"id":932,"depth":87,"text":933},{"id":942,"depth":87,"text":943},{"id":964,"depth":87,"text":965},"Build an in-memory fake of a data repository that behaves like the real one: identity, uniqueness, ordering, error cases, and a shared contract suite that keeps it honest.","md",{"slug":1018,"type":1019,"breadcrumb":1020,"datePublished":1021,"dateModified":1021,"faq":1022,"howto":1029},"writing-an-in-memory-fake-repository","article","In-Memory Fake","2026-09-18",[1023,1025,1027],{"q":948,"a":1024},"Copies. The real repository returns fresh objects from the database, so mutating one after saving does not change what is stored. A fake that stores references lets tests pass because a later mutation 'saved' itself, which the real repository would never do.",{"q":954,"a":1026},"Everything the code under test depends on, including the error cases — not found, duplicate key, optimistic-lock conflict — and ordering guarantees. It does not need transactions, connection handling or query planning. The contract suite defines exactly where the line is.",{"q":960,"a":1028},"A shared contract test suite that runs against both. Any behaviour the code relies on is asserted once, and both implementations must pass. The real half runs in the integration stage; the fake half runs everywhere.",{"name":1030,"description":1031,"steps":1032},"How to write an in-memory fake repository","Implement the repository protocol over a dictionary, reproduce error cases and ordering, store copies, and verify against the real implementation with a shared suite.",[1033,1036,1039,1042,1045],{"name":1034,"text":1035},"Start from the protocol","Implement exactly the methods the code under test calls, typed against the same Protocol as the real repository.",{"name":1037,"text":1038},"Store copies, not references","Deep-copy on save and on load so mutation after save does not change stored state.",{"name":1040,"text":1041},"Reproduce the error cases","Raise the same NotFound, Duplicate and Conflict exceptions the real repository raises in the same situations.",{"name":1043,"text":1044},"Reproduce ordering guarantees","Sort list results the way the real query does, since tests will depend on the order.",{"name":1046,"text":1047},"Run a shared contract suite","Parametrise one behavioural suite over both implementations so divergence fails immediately.","\u002Fadvanced-mocking-test-doubles-in-python\u002Fspies-fakes-and-hand-rolled-test-doubles\u002Fwriting-an-in-memory-fake-repository",{"title":5,"description":1015},"advanced-mocking-test-doubles-in-python\u002Fspies-fakes-and-hand-rolled-test-doubles\u002Fwriting-an-in-memory-fake-repository\u002Findex","s-uANK_1-vOw8onbZisXW2dy02RMRfCOT2IvfgHQ8Nw",1789718768933]