Christoph needs to test his logic, but he must pry it from the clutches of side effects.
- Last week we ended up with “imperative mud”: lots of nested I/O and logic.
- (01:45) Difficult to test when all the I/O and logic are mixed together.
- Object-oriented programming advocates for “mocking” objects with side-effects
- Mocking reinforces and expands the complexity
- Complexity stems from all the different branches of execution: mainline, exceptions, corner cases, etc.
- With the imperative approach, no way to just “jump” to step 2 and test that in isolation. Have to setup all the mocks to tilt the flow of control in the desired direction.
- The Go language handles the “error flow” problem by making error returns explicit (no exceptions), but that further compounds the branching problem.
- (05:25) A different approach to deal with nesting: “happy path” route
- Mainline of execution is the most common case.
- Any error is thrown which has to be handled out of band.
- “Exceptions are much better at mocking you than you are at mocking them.”
- Even more problems with mocking: suppose you want to test exponential backoff
- API handle mock has to fail a couple of times and then succeed.
- Requires a “stateful” mock!
- “You have whole object hierarchies that are only in use for your tests!”
- (07:35) Key observation: imperative programming uses the location of execution as a way of encoding information.
- Code in the first branch of the “if” statement “knows” something different than code in the second branch.
- Position in the flow of control is implicit information
- Change implicit information to explicit information and you can flatten out the code.
- Implicit: “You are here because you did X.”
- Explicit: “Separating where I am from what to do next.”
- “In an imperative flow, the fact you made it to line 3 means something.”
- (09:55) To get out of the problem of nesting, we need to have positive, represented information instead of implicit, contextual information.
- Pattern is: logic, I/O, logic, I/O, logic, I/O, etc.
- Capture each one as a multimethod: a “worker” method and a “decider” method.
- The “worker” method dispatches on a “command”, just does one thing, and returns the result.
- “My job is not to question why. My job is but to do or die.”
- “I don’t know if we need to get into the class struggle.”
- The “decider” method takes a command and a result, looks at them, decides what to do next, returns a command.
- (18:50) You can work out all the scenarios, and the logic for each scenario is pure!
- Testing becomes a matter of setting the data
- Steps can be tested independently
- (19:45) A “result” isn’t simply spewing back the raw data from the I/O
- Worker picks out the relevant parts and return those
- Use spec or schema to document responses
- Can use
metaor a nested key (like
:raw) to attach raw data for debugging or the “attempt” log (see Ep 022)
- Logic should operate on well-known fields, but recording raw responses is useful for telling a story later on.
- (22:45) Main flow of execution becomes a simple
commandto worker and bind
responseto decider and bind
(when new-command (recur new-command))
- (24:30) A downside of the approach: it can be harder to see cases that are not covered, like an unsupported command or unhandled response
- Once again, unit tests can help ensure coverage for the “deciders”.
- (27:30) How to test the worker?
- They don’t have any logic except doing the side effect.
- Even “picking” the right values from the data could be factored as a pure function
- Can test with the REPL. Try out the side effect and then leave it alone.
- (29:50) Application testing creates another problem
- What about testing the UI? We want to see changes in the data, but we don’t want to actually post for real.
- Great topic for next time.
Message Queue discussion:
- (32:05) Do we post the source code for episodes?
- We post some code in the show notes, but less so for a high-level series.
- Send us links to your code and we’ll put them on the website.
Clojure in this episode: