4 ms·
Hey all, author here. I'm a bit late to the party but happy to answer any questions you have. The main question seems to be: what's the difference between null
by jdlshore 5y ago
Hey all, author here. I'm a bit late to the party but happy to answer any questions you have.
The main question seems to be: what's the difference between null implementations and mocks?
There's a practical answer to that question and a technical answer.
Practically speaking, the reason to use nullable infrastructure wrappers is that they enable sociable tests. A flaw of mock-based testing is that you only check that your system under test (SUT) calls its dependencies, but not that your code's expectations of its dependencies are correct. Sociable tests check that the SUT uses its dependencies correctly. This is valuable when refactoring, because it's easy to inadvertently introduce changes to behavior that break callers. Mock-based tests ("solitary tests") can't detect those errors.
Technically speaking, the nullable infrastructure wrappers convert your tests from interaction-based ("did I call the right methods") to state-based ("did my system return the correct results or make the appropriate state changes"). They do this by adding state inspection capabilities to production code, allowing you to assert on the state of the production code rather than asserting which methods were called. They also implement a tiny hidden stub to enable you to "turn off" interactions with the outside world.
My approach and mocks/spies are superficially similar, in that they both make use of test doubles (stubs, mocks, and spies are all test doubles) but they lead to significantly different testing approaches. Mocks/spies lead to interaction-based solitary tests, and nullable infrastructure leads to state-based sociable tests. I also find the tests are easier to write and read, and they're orders of magnitude faster¹ than using a mocking framework, too.
¹In a head-to-head comparison, nullable infrastructure ran 1,075,268 tests/sec, testdouble.js ran 12,210 tests/sec, and sinon.js ran 2,793 tests/sec. https://github.com/jamesshore/livestream/blob/2020-05-26-end/src/_comparisons_test.js https://github.com/jamesshore/livestream/blob/2020-05-26-end...
- etamponi 5y agoThank you for your clarification (and for the article). Could you elaborate a bit more? I see the point of sociable tests, but I don't see how nullable infrastructure does not break the pattern itself. Or, actually, I only see one way in which it does not break sociable tests: if the null implementation is tested against the _real_ implementation, guaranteeing that it does not drift away. At this point, using the null version of a wrapper in another test is guaranteed to test the correct interaction with the infrastructure. Is this it, or is there something else that I am missing?
- jdlshore 5y agoI don't fully understand your question (I don't know what you mean by breaking the pattern), but I'll give it a shot. The short version is that the "Null" uses your real production code. The mock doesn't. Let's say you have some login page that uses Auth0. You have a controller for the page and an infrastructure wrapper (aka gateway) for Auth0. You want to test that your login controller logic is implemented correctly. When you test your controller with a mock, your code goes through this path: Test --> Controller --> Mock But your real code does this: Controller --> Auth0 Wrapper -/\/\/ (HTTPS request) \/\/\-> Auth0 Service So you're vulnerable. If you ever refactor your Auth0 wrapper so it behaves differently than your controller expects, none of your tests will catch it, even if you have good tests of both Controller and Auth0 Wrapper. You can prevent this vulnerability by writing an integration test: Test --> Controller --> Auth0 Wrapper -/\/\/-> Auth0 Service But those are slow and brittle, and a lot of work to set up. Your test of Controller has to know all sorts of details about Auth0 Service, which requires you to know about the implementation details of Auth0 Wrapper. But you're just trying to test Controller, and you want Auth0 Wrapper to hide those details from you. So instead, if you implement nullable infrastructure, your Auth0 Wrapper would work like this: +-/\/\/-> Auth0 Service / Auth0 Wrapper + \ +--> Null Auth0 Service At runtime, you can choose to call the `create()` factory method, and get an Auth0 Wrapper that talks to Auth0 Service, or you can call the `createNull()` factory method, and get an Auth0 Wrapper that doesn't talk to the Auth0 Service. Either way, the service is encapsulated and you don't have to know any of the details about how Auth0 Service works. So, in your test of Controller, you can inject the `createNull()` version of Auth0 Wrapper, and now your test looks like this: Test --> Controller --> Auth0 Wrapper --> Null Auth0 Service You get all the benefits of an integration test without the costs of an integration test. You don't have to know the internals of how Auth0 Wrapper is implemented, you don't have to set up Auth0 Service, and you don't make a network call. So, in the future, if Auth0 Wrapper changes in a way that breaks Controller, Test fails. If you were using a mock, it wouldn't. I have a bunch of coding livestreams with practical examples at https://www.jamesshore.com/v2/projects/lunch-and-learn https://www.jamesshore.com/v2/projects/lunch-and-learn. Check out episode 4 for the basics, and episodes 5-8 (and beyond) for an example of implementing a real world microservice architecture using these ideas.