Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Edwin Jung - Mocking and Patching Pitfalls

Edwin Jung - Mocking and Patching Pitfalls

Mocking and patching are powerful techniques for testing, but they can be easily abused, with negative effects on code quality, maintenance, and application architecture. These pain-points can be hard to verbalize, and consequently hard to address. If your unit tests are a PITA, but you cannot explain why, this talk may be for you.

Mocking as a technique has deep roots within OOD and TDD, going back 20+ years, but many Python developers know mocks and patches merely as a technique to isolate code under test. In the absence of knowledge around OOD and TDD, best practices around mocking are completely unknown, misunderstood, or ignored. Developers who use mocks and patches without doing TDD or OOD are susceptible to falling into many well-understood and documented traps.

This talk will draw a historical connection between the way mocks are taught today, and their origins in TDD, OOD, and Java. It will also demonstrate some pitfalls, and provide some guidance and alternatives to mocking and patching (e.g., dependency injection, test doubles, functional style).

https://us.pycon.org/2019/schedule/presentation/216/

PyCon 2019

May 03, 2019
Tweet

More Decks by PyCon 2019

Other Decks in Programming

Transcript

  1. Mocking and Patching Pitfalls Misadventures In Mocking A People’s History

    of Mocking Mocking: Forms and Perversions Mocking: A Polemic Mock Hell PyCon 2019
  2. who i am • Staff Software Engineer, Platform @ Quid

    • Data engineering and microservices • Python is ~4th language email: [email protected] twitter: @ejjcatx github: xtaje
  3. who you are • Have used mocking or patching before.

    • Use the mock and patch APIs regularly, but are not sure why it’s sometimes painful. • Comfortable with mocks, but want to know more about different options. Advanced Beginner
  4. what you might learn • What are the styles of

    unit testing • Where did mocks come from? • What are alternatives to mocking? • Anti-patterns • Relationship between mocking and design Advanced Beginner
  5. 1. complex patch targets 2. numerous mocks or patches 3.

    mocks with brittle assertions 4. mocks with complex setup 5. deep mocks/recursive mocks/ mocks returning mocks 6. tests that test nothing 7. using the debugger to reverse- engineer mocks 8. mocks that prevent refactoring
  6. 1. Mock is a really cool library used to isolate

    code for testing. 2. Here’s how to use MagicMock, patch, spec, autospec, side_effect, etc. 3. Patching is tricky, use the right namespace target. the promise
  7. # my_module.py from db import db_read def total_value(item_id): items =

    db_read(item_id) return sum(items) code under test
  8. mock patch # test_my_module.py from unittest.mock import patch from my_module

    import total_value def test_total_value(): with patch('my_module.db_read') as db_read: db_read.return_value = [100, 200] assert 300 == total_value(1234) db_read.assert_called_with(1234)
  9. Given some topic of interest (e.g. “Brexit”), Find all relevant

    articles from a particular time period, and Feed those article urls to a scraper service.
  10. { "response": { "status": "ok", “total": 1113, "startIndex": 1, "pageSize":

    10, "currentPage": 1, "pages": 112, “results": [ { "id": “world/2019/mar/21/thursday-briefing-mays-brexi...”, "type": "article", "webPublicationDate": "2019-03-21T06:30:38Z", "webTitle": "Thursday briefing: May's Brexit broadside", "webUrl": “https://www.theguardian.com/world/2019/m...”, }, . . . and 9 more results ] } }
  11. import redis import requests class Feed(object): + ---- < def

    __init__ > --------------------------------- + ---- < def format_request > --------------------------- + ---- < def run > -------------------------------------- feed.py
  12. feed.py import redis import requests class Feed(object): def __init__(self, query,

    redis_host=None, page_size=None, date_range=None): self._query, self.page_size = query, page_size self.date_range = date_range or (None, None) self._redis_con = redis.Redis(redis_host or "localhost") self._session = requests.Session() + ---- < def format_request > --------------------------------- + ---- < def run > --------------------------------------------
  13. feed.py import redis import requests class Feed(object): + ---- <

    def __init__ > --------------------------------- + ---- < def format_request > --------------------------- + ---- < def run > --------------------------------------
  14. feed.py import redis import requests class Feed(object): + ---- <

    def __init__ > -------------------------------------- def format_request(self, page): def param(key, val): return {key: val} if val is not None else {} params={"page": page, "query": self._query, "api-key": _APIKEY } params.update(param("page_size", self.page_size)) # repeat for from-date, to-date return Request('GET', _BASE_URL, params=params).prepare() + ---- < def run > -------------------------------------------
  15. import redis import requests class Feed(object): + ---- < def

    __init__ > --------------------------------- + ---- < def format_request > --------------------------- + ---- < def run > -------------------------------------- feed.py
  16. import redis import requests class Feed(object): + ---- < def

    __init__ > -------------------------------------- + ---- < def format_request > -------------------------------- def run(self): page, page_count = (1, 1) while page <= page_count: rsp = self._session.send(self.format_request(page)) rsp.raise_for_status() response = rsp.json()['response'] page_count = response['pages'] results = response[‘results'] def push(web_url): self._redis_con.rpush("scrape_urls", web_url) list(map(push, (r['webUrl'] for r in results))) page += 1 feed.py
  17. @patch("feed.redis.Redis", autospec=True) @patch("feed.requests.Session", autospec=True) @patch("feed.Request", autospec=True) def test_run(request_mock, session_mock, mock_Redis):

    mock_response = MagicMock(spec=requests.Response) mock_response.json.return_value = { "response": { "pages": 1, "results": [ {"webUrl": "http://article1"}, {"webUrl": "http://article2"} ] } } session_mock.return_value.send.return_value = mock_response Feed("brexit").run()
  18. @patch("feed.redis.Redis", autospec=True) @patch("feed.requests.Session", autospec=True) @patch("feed.Request", autospec=True) def test_run(request_mock, session_mock, mock_Redis):

    mock_response = MagicMock(spec=requests.Response) mock_response.json.return_value = { "response": { "pages": 1, "results": [ {"webUrl": "http://article1"}, {"webUrl": "http://article2"} ] } } session_mock.return_value.send.return_value = mock_response Feed("brexit").run() request_mock.return_value.prepare.assert_called() params = {"page": 1, "api-key": ‘<apikey>', 'query': 'brexit'} expected_call = mock.call("GET", _GUARDIAN_URL, params=params) assert expected_call == request_mock.call_args
  19. @patch("feed.redis.Redis", autospec=True) @patch("feed.requests.Session", autospec=True) @patch("feed.Request", autospec=True) def test_run(request_mock, session_mock, mock_Redis):

    mock_response = MagicMock(spec=requests.Response) mock_response.json.return_value = { "response": { "pages": 1, "results": [ {"webUrl": "http://article1"}, {"webUrl": "http://article2"} ] } } session_mock.return_value.send.return_value = mock_response Feed("brexit").run() request_mock.return_value.prepare.assert_called() params = {"page": 1, "api-key": ‘<apikey>', 'query': 'brexit'} expected_call = mock.call("GET", _GUARDIAN_URL, params=params) assert expected_call == request_mock.call_args mock_Redis.assert_called_with("localhost") expected_calls = [call('scrape_urls', 'http://article1'), ... ] assert expected_calls == mock_Redis.return_value.rpush.call_args_list
  20. @patch("feed.redis.Redis", autospec=True) @patch("feed.requests.Session", autospec=True) @patch("feed.Request", autospec=True) def test_run(request_mock, session_mock, mock_Redis):

    mock_response = MagicMock(spec=requests.Response) mock_response.json.return_value = { "response": { "pages": 1, "results": [ {"webUrl": "http://article1"}, {"webUrl": "http://article2"} ] } } session_mock.return_value.send.return_value = mock_response Feed("brexit").run() request_mock.return_value.prepare.assert_called() params = {"page": 1, "api-key": ‘<apikey>', 'query': 'brexit'} expected_call = mock.call("GET", _GUARDIAN_URL, params=params) assert expected_call == request_mock.call_args mock_Redis.assert_called_with("localhost") expected_calls = [call('scrape_urls', 'http://article1'), ... ] assert expected_calls == mock_Redis.return_value.rpush.call_args_list How many mocks? A. 3 B. 4 C. __
  21. @patch("feed.redis.Redis", autospec=True) @patch("feed.requests.Session", autospec=True) @patch("feed.Request", autospec=True) def test_run(request_mock, session_mock, mock_Redis):

    mock_response = MagicMock(spec=requests.Response) mock_response.json.return_value = { "response": { "pages": 1, "results": [ {"webUrl": "http://article1"}, {"webUrl": "http://article2"} ] } } session_mock.return_value.send.return_value = mock_response Feed("brexit").run() request_mock.return_value.prepare.assert_called() params = {"page": 1, "api-key": ‘<apikey>', 'query': 'brexit'} expected_call = mock.call("GET", _GUARDIAN_URL, params=params) assert expected_call == request_mock.call_args mock_Redis.assert_called_with("localhost") expected_calls = [call('scrape_urls', 'http://article1'), ... ] assert expected_calls == mock_Redis.return_value.rpush.call_args_list How many mocks? A. 3 B. 4 C. 11
  22. @patch("feed.redis.Redis", autospec=True) @patch("feed.requests.Session", autospec=True) @patch("feed.Request", autospec=True) def test_run(request_mock, session_mock, mock_Redis):

    mock_response = MagicMock(spec=requests.Response) mock_response.json.return_value = { "response": { "pages": 1, "results": [ {"webUrl": "http://article1"}, {"webUrl": "http://article2"} ] } } session_mock.return_value.send.return_value = mock_response Feed("brexit").run() request_mock.return_value.prepare.assert_called() params = {"page": 1, "api-key": ‘<apikey>', 'query': 'brexit'} expected_call = mock.call("GET", _GUARDIAN_URL, params=params) assert expected_call == request_mock.call_args mock_Redis.assert_called_with("localhost") expected_calls = [call('scrape_urls', 'http://article1'), ... ] assert expected_calls == mock_Redis.return_value.rpush.call_args_list
  23. is this a violation of information hiding? encapsulation? @patch("feed.redis.Redis", autospec=True)

    @patch("feed.requests.Session", autospec=True) @patch("feed.Request", autospec=True)
  24. @patch("feed.redis.Redis", autospec=True) @patch("feed.requests.Session", autospec=True) @patch("feed.Request", autospec=True) def test_run(request_mock, session_mock, mock_Redis):

    mock_response = MagicMock(spec=requests.Response) mock_response.json.return_value = { "response": { "pages": 1, "results": [ {"webUrl": "http://article1"}, {"webUrl": "http://article2"} ] } } session_mock.return_value.send.return_value = mock_response Feed("brexit").run() request_mock.return_value.prepare.assert_called() params = {"page": 1, "api-key": ‘<apikey>', 'query': 'brexit'} expected_call = mock.call("GET", _GUARDIAN_URL, params=params) assert expected_call == request_mock.call_args mock_Redis.assert_called_with("localhost") expected_calls = [call('scrape_urls', 'http://article1'), ... ] assert expected_calls == mock_Redis.return_value.rpush.call_args_list
  25. def test_run(request_mock, session_mock, mock_Redis): mock_response = MagicMock(spec=requests.Response) mock_response.json.return_value = {

    session_mock.return_value.send.return_value = mock_response request_mock.return_value.prepare.assert_called() assert expected_calls == mock_Redis.return_value.rpush.call_args_list law of demeter violation?
  26. what if you change imports? import redis import requests class

    Feed(object): + ---- < def __init__ > --------------------------------- + ---- < def format_request > --------------------------- + ---- < def run > --------------------------------------
  27. from redis import Redis import requests class Feed(object): + ----

    < def __init__ > --------------------------------- + ---- < def format_request > --------------------------- + ---- < def run > -------------------------------------- what if you change imports?
  28. kwargs to args? class Feed(object): + ---- < def __init__

    > --------------------------------- def format_request(self, page): def param(key, val): return {key: val} if val is not None else {} params={"page": page, "query": self._query, "api-key": _APIKEY } params.update(param("page_size", self.page_size)) # repeat for from-date, to-date return Request('GET', _BASE_URL, params=params).prepare() + ---- < def run > --------------------------------------
  29. kwargs to args? class Feed(object): + ---- < def __init__

    > --------------------------------- def format_request(self, page): def param(key, val): return {key: val} if val is not None else {} params={"page": page, "query": self._query, "api-key": _APIKEY } params.update(param("page_size", self.page_size)) # repeat for from-date, to-date return Request('GET', _BASE_URL, params).prepare() + ---- < def run > --------------------------------------
  30. broken @patch("feed.redis.Redis", autospec=True) @patch("feed.requests.Session", autospec=True) @patch("feed.Request", autospec=True) def test_run(request_mock, session_mock,

    mock_Redis): # more code above ... Feed("brexit").run() request_mock.return_value.prepare.assert_called() params = {"page": 1, "api-key": ‘<apikey>', 'query': 'brexit'} expected_call = mock.call("GET", _GUARDIAN_URL, params=params) assert expected_call == request_mock.call_args # more code below...
  31. 1. Mock is a really cool library used to isolate

    code for testing. 2. Here’s how to use Mock, MagicMock, patch, spec, autospec, side_effect, etc. 3. Patching is tricky, use the right namespace target. 4. (And by the way, be careful about over-mocking.) 5. (Mocks aren’t stubs) fine print
  32. • PyCon Cleveland 2018 • EuroPython 2018 • PyCon UK

    2018 • PyCon Australia 2017 • PyCon CZ 2017 • PyCon Israel 2017 • EuroPython 2016 • PyCon Portland 2016 • PyCon UK 2015 • PyCon SG 2015 mock talk at PyCons
  33. A: … Patches can definitely get out of hand …

    … you maybe need to look into why there is so much heavy mocking. “Q&A: Demystifying the Patch Function” -PyCon 2018
  34. Q: I’m Michael Foord. I was the original author of

    mock… “Q&A: Demystifying the Patch Function” -PyCon 2018
  35. Q: “The more you have to patch, the worse your

    code is.” “Q&A: Demystifying the Patch Function” -PyCon 2018
  36. ? !

  37. Mock Objects is an extension to Test Driven Development that

    supports good Object- Oriented design …
  38. It turns out to be less interesting as a technique

    for isolating tests … than is widely thought.
  39. 1. TDD 2. Object Oriented Design 3. mocks are not

    a tool for isolation mocks in context
  40. 1. rapid cycles of small refactoring 2. Object Oriented Design

    3. mocks are not a tool for isolation mocks in context
  41. 1. rapid, cycles of small refactoring 2. object collaborations 3.

    mocks are not a tool for isolation mocks in context
  42. 1. rapid, cycles of small refactoring 2. object collaborations 3.

    mocks are a tool for exploratory design and discovery mocks in context
  43. 1. rapid, cycles of small refactoring 2. object collaborations 3.

    mocks are a tool for exploratory design and discovery mocks in context
  44. Always choose interaction testing [mocking]… as the last option. This

    is very important… Not everyone agrees with this point of view ...
  45. I’m not fully disagreeing … but for maintainability … tests

    using mocks creates more trouble than it’s worth.
  46. Mocking frameworks are potent tools that let you wield incredible

    power. However, power nearly always corrupts. …
  47. test doubles name behavior mock records calls to the object

    stub returns canned data, no logic fake implements fake version of production logic dummy does nothing spy records and delegates to the real thing
  48. # test_my_module.py from unittest.mock import patch from my_module import total_value

    def test_total_value(): with patch('my_module.db_read') as db_read: db_read.return_value = [100, 200] assert 300 == total_value(1234) db_read.assert_called_with(1234) remember mock patch? mock stub
  49. alternative: fake patch # test_my_module.py from unittest.mock import patch from

    my_module import total_value def test_total_value(): with patch('my_module.db_read'): assert 300 == total_value(1234)
  50. alternative: fake patch # test_my_module.py from unittest.mock import patch from

    my_module import total_value def test_total_value(): def fake_db_read(item_id): with open(f'./fixtures/{item_id}.json') as fobj: return json.load(fobj) with patch('my_module.db_read'): assert 300 == total_value(1234)
  51. alternative: fake patch # test_my_module.py from unittest.mock import patch from

    my_module import total_value def test_total_value(): def fake_db_read(item_id): with open(f'./fixtures/{item_id}.json') as fobj: return json.load(fobj) with patch('my_module.db_read', new=fake_db_read): assert 300 == total_value(1234)
  52. alternative: fake injection # my_module.py from db import db_read def

    total_value(item_id): items = db_read(item_id) return sum(items) # my_module.py def total_value(item_id, db_read): items = db_read(item_id) return sum(items)
  53. tactical questions 1.Which Test Doubles (mock or other)? 2.Mockist or

    Classical? (aka London or Detroit) 3.Patch or Inject?
  54. Mock Roles, not Objects Tactics 3 Dependency Injection Tactics 4

    Inject the Collaborator Tactics 5 Go Functional Tactics 1 Find a Seam Patch a Fake
  55. Mock Roles, not Objects Tactics 2 Dependency Injection Tactics 3

    Inject the Collaborator Tactics 4 Go Functional Tactics 1 Find a Seam Patch a Fake
  56. seam “A seam is a place where you can alter

    behavior in your program without editing in that place.”
  57. Feed test Session Request redispy redis server find a different

    “seam” urllib3 requests internals TCP guardian
  58. Feed test Session Request redispy redis server patch a fake

    into a transport layer “seam” HTTMock guardian fakeguard ian
  59. @httmock.urlmatch(netloc=r’content.guardianapis.com') def fake_guardian(url, request): page, page_size = parse_query_params(url.query) article_ids =

    range(page_size*(page-1)+1, page_size*page+1) results = [{"webUrl": "http://article%d" % (i)} for i in article_ids] fake_response = { "response": { "currentPage": page, "pages": 2, "results": results } } return json.dumps(fake_response)
  60. from urllib.parse import parse_qs from httmock import urlmatch, HTTMock @mock.patch("feed.redis.Redis",

    autospec=True) def test_run(mock_Redis): with httmock.HTTMock(fake_guardian): Feed("brexit").run() mock_Redis.assert_called_with("localhost") expected_calls = [mock.call('scrape_urls', f'http://a{i}') for i in range(1, 21)] assert expected_calls == mock_Redis.return_value.rpush.call_args_list)
  61. Mock Roles, not Objects Tactics 2 Dependency Injection Tactics 3

    Inject the Collaborator Tactics 4 Go Functional Tactics 1 Find a Seam Patch a Fake
  62. inject the redis connection class Feed(object): def __init__(self, query, redis_host=None,

    page_size=None, date_range=None): self._query = query self.page_size = page_size self.date_range = date_range or (None, None) self._redis_con = redis.Redis(redis_host or "localhost") self._session = requests.Session() + ---- < def format_request > --------------------------- + ---- < def run > --------------------------------------
  63. inject the redis connection class Feed(object): def __init__(self, query, redis_con,

    page_size=None, date_range=None): self._query = query self.page_size = page_size self.date_range = date_range or (None, None) self._redis_con = redis_con self._session = requests.Session() + ---- < def format_request > --------------------------- + ---- < def run > --------------------------------------
  64. from httmock import urlmatch, HTTMock from feed import Feed import

    fakeredis def test_run(): fake_con = fakeredis.FakeStrictRedis() with HTTMock(fake_guardian): Feed("brexit", fake_con).run() expected = [f"http://article{i}" for i in range(1, 21)] received = [fake_con.lpop("scrape_urls").decode("utf-8") for _ in range(1,21)] self.assertEqual(expected, received)
  65. Mock Roles, not Objects Tactics 3 Dependency Injection Tactics 4

    Inject the Collaborator Tactics 5 Go Functional Tactics 1 Find a Seam Patch a Fake
  66. inject the collaborator class Feed(object): def __init__(self, query, redis_con, page_size=None,

    date_range=None): self._query = query self.page_size = page_size self.date_range = date_range or (None, None) self._redis_con = redis_con self._session = requests.Session() + ---- < def format_request > --------------------------- + ---- < def run > --------------------------------------
  67. inject the collaborator class Feed(object): def __init__(self, query, sink, page_size=None,

    date_range=None): self._query = query self.page_size = page_size self.date_range = date_range or (None, None) self._sink = sink self._session = requests.Session() + ---- < def format_request > --------------------------- + ---- < def run > --------------------------------------
  68. class Feed(object): + ---- < def __init__ > --------------------------------- +

    ---- < def format_request > --------------------------- def run(self): page, page_count = (1, 1) while page <= page_count: rsp = self._session.send(self.format_request(page)) rsp.raise_for_status() response = rsp.json()['response'] logger.debug(response) page_count = response['pages'] results = response['results'] list(map(self._sink, (r['webUrl'] for r in results))) page += 1
  69. from httmock import urlmatch, all_requests, HTTMock from feed import Feed

    def test_run(self): received = [] with HTTMock(fake_guardian): Feed("brexit", received.append).run() expected = [f"http://article{i}" for i in range(1, 21)] self.assertEqual(expected, received)
  70. Mock Roles, not Objects Tactics 3 Dependency Injection Tactics 4

    Inject the Collaborator Tactics 5 Go Functional Tactics 1 Find a Seam Patch a Fake
  71. glue code def feed(query): s = requests.Session() first_page = s.send(initial_request(query))

    all_pages = map(s.send, page_requests(first_page.json(), query=query)) web_urls = munge(all_pages) redis.Redis("localhost").rpush("scrape_urls", *web_urls) if __name__ == "__main__": feed("brexit")
  72. pure functions def flatmap(f, items): return itertools.chain.from_iterable(map(f, items)) def param(key,

    val): return {key: val} if val is not None else {} def format_request(query, page, page_size = None, date_range=None): date_range = date_range or (None, None) params= { "page": page, "query": query, "api-key": _APIKEY } params.update(param("page_size", page_size)) params.update(param("start_date", date_range[0])) params.update(param("end_date", date_range[1])) return Request('GET', _BASE_URL, params=params).prepare() def initial_request(query, page_size=None, date_range=None): return format_request(query, page=1, page_size=page_size, date_range=date_range) def page_requests(body, query): response = body['response'] page_count = min(3, int(response['pages'])) # IRL needs flow control for page in range(1, page_count+1): params = {"page": page, "query": query, "api-key": _APIKEY} yield Request('GET', _BASE_URL, params=params).prepare() def munge(all_pages): bodies = map(lambda r: r.json(), all_pages) results = flatmap(lambda body: body['response']['results'], bodies) return map(lambda result: result['webUrl'], results)
  73. • Aim for 100% test isolation, 100% coverage • Test

    Whenever, No Refactoring • Procedural Decomposition or Top-Down Design (no OOP) bootleg TDD
  74. • Aim for 100% test isolation, 100% coverage • Test

    Whenever, No Refactoring • Procedural Decomposition or Top-Down Design (no OOP) bootleg TDD
  75. procedural decomposition “In each step, one or several instructions of

    a program are decomposed into more detailed instructions”
  76. P

  77. C D E test test test “. . . mock

    the db connection?” “. . . mock the query language?”
  78. Abstract Detailed 1.Top down design => no dependency inversion 2.Patching

    + mocking “locks in” design decisions Patching prevents understanding of dependency inversion Issues?
  79. P

  80. issues • What kind of test is this? • Is

    this • pragmatic or • just enabling bad habits?
  81. propositions 1.A test should test a single behavior. 2.A patch

    is a violation of some encapsulation boundary. 3.A test with many patches is violating multiple encapsulation boundaries. A test case with a lot of patches may represent a problem with cohesion or coupling. A test case with a lot of patches may represent a crosscutting concern.
  82. opinions • always be refactoring • consider other test doubles

    • patching • should be rare, and the last tool you use • mocks (if you use them) • should target roles and not objects • are not for just for test isolation
  83. Debates and Counter-opinions • Dependency Injection is Not a Virtue

    (DHH) • TDD is Dead (Martin Fowler, Kent Beck, DHH) • Why Most Unit Testing is a Waste (Coplien) • The TDD Apostate (Seeman) Resources • Mocks aren’t Stubs (Fowler) • Clean Code (Uncle Bob) • Working Effectively with Legacy Code (Feathers) • Growing Object Oriented Software Guided by Tests • The Art of Unit Testing (Osherove) email: [email protected] twitter: @ejjcatx