3 ms·
Thank 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 infrast
by etamponi 5y ago
Thank 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.
- etamponi 5y agoThank you! I think I've got it now. So using the createNull version is very very similar to using a spy server, with the difference that with the null version the request is not sent at all. If I can, may I ask you another question? How do you inject the createNull version in a test that does not have the architecture wrapper as a direct dependency? Do you manually unravel the dependencies? For example: Test -> UpperController -> Controller -> ArchitectureWrapper How do you test UpperController with a createNull version of ArchitectureWrapper? Or is it necessary to always have the architecture as a direct dependency of the SUT? (As mandated by the A-frame architecture) Thanks
- jdlshore 5y ago> similar to using a spy server I hadn't thought of it that way, but I think that's exactly correct. > How do you inject the createNull version in a test that does not have the [infrastructure] wrapper as a direct dependency? The short answer is that you create a chain of nullable objects. Infrastructure "pollutes" its callers, in that having intrastructure in the dependency chain exposes you to the downsides of infrastructure (external state, network calls, unreliability, etc.). So I consider deep dependencies on infrastructure a design smell. Hence A-Frame Architecture. That said, design is all about tradeoffs, and sometimes a deep dependency on infrastructure is better than the alternatives. Your example of UpperController and Controller is something I'd prefer to avoid, but it's normal for me to have "high-level" infrastructure that depends on "low-level" infrastructure. For example, let's say we implement a logging wrapper. Under the covers, it depends on CommandLine, a wrapper for stdout. When used by Controller, the chain looks like this: Controller --> Log --> CommandLine --> process.stdout CommandLine exposes trackStdout()¹, which allows you to record what was written to stdout, and createNull(), which allows you to create a CommandLine that doesn't actually write to stdout. ¹trackStdout() is a more sophisticated alternative to the "Send Events" and "Send State" (getLastOutput) patterns mentioned in the 2018 article. But Controller isn't aware of CommandLine. Controller is only aware of Log. Log's use of CommandLine is an encapsulated implementation detail that's hidden from the rest of the world. So Log also exposes methods to help you test. It has trackOutput(), which allows you to record what was written to the log, and createNull(), which allows you to create a Log that doesn't actually write to the log. Importantly, here, trackOutput() isn't just calling CommandLine.trackOutput(). It's providing a higher-level abstraction that knows how to deal with structured logs, timestamps, and stack traces. So in the test of Controller, your code looks like this (JavaScript): it("logs stuff", () => { const log = Log.createNull(); const logOutput = log.trackOutput(); const controller = new Controller(log); controller.doStuff(); assert.deepEqual(logOutput, [ { message: "my expected log line 1", detail: "some detail" }, { message: "my expected log line 2", detail: "some other detail" }, ]); }); This isn't a lot of work for Log, because it can use the abstractions provided by CommandLine. In particular, its factory methods look like this: static create() { return new Log(CommandLine.create()); } static createNull() { return new Log(CommandLine.createNull()); } I have a video for this example at [2] and the source code is at [3]. [2] https://www.jamesshore.com/v2/projects/lunch-and-learn/testable-logs https://www.jamesshore.com/v2/projects/lunch-and-learn/testa... [3] https://github.com/jamesshore/livestream/blob/2020-08-11-end/src/infrastructure/log.js https://github.com/jamesshore/livestream/blob/2020-08-11-end...