We need to talk about OOP, Mocks, & Pain.
When I started on an Angular team, I got really irritated about all the as any / as Partial<T> for all the mocks/stubs the various teams were using in tests. I’ve learned over the years to assume empathy, but this rubbed me the wrong way.
For one, Angular was one of the first frameworks to lead with TypeScript. They put types first and foremost, drawing ire from many in the web world which continues to this day. To see this many teams discarding a core feature & tenant told me something was wrong.
If developers aren’t using a core feature, and core philosophy, then there is some strong counter factor at work. Incentives? Pressure? Exposure? Ergonomics?
Company’s culture around automated testing & Continuous Delivery is great. Types, same as the industry; “ok”. No problem.
Pressure is more towards visualizations and functionality; unit tests only helps so much there, hence a huge focus on Acceptance Tests via Cypress & a custom browser extension for manual smoke tests against an environment with prod-safe data.
No pressure problem.
I did find a few core testing utilities and example tests which did mock/stub core classes. I even found some abandoned ones; you could tell because the Partial<T> had things that didn’t belong; the dev got 12 properties through a 32 property DTO and just gave up midway through.
So not an exposure problem. Not an incentive problem. Not a pressure problem. That leaves ergonomics. So I did a lot of tests myself, intentionally, up and down the stack. Maybe it was the code base itself?
I wanted to get my hands dirty:
- lower level (e.g.
fetch/this.http.post) - mid-level (our platforms abstractions)
- high-level (UI components and what they publicly expose)
I found out it was an ergonomic problem.
Classes/Interfaces required a ton of code to stub it. To avoid this, you can use 1 line of mutation using Jest or Vitest to change a method via jest.spyOn/vi.spyOn. The added bonus is it is type-safe.
Why use DI with lots of code when you can use a 1 line type-safe mock?
This in turn caused intentionally NOT using DI as much as possible, a core Angular feature, in unit tests. This made the unit tests harder to manage because of all the state. Implementation in your code change, but no behavior change? Tests break anyway.
Suddenly the lines of code saved by the “1 line mock” no longer was positively visible amongst the nested before/beforeEach/afterEach, and multiple mocks needed above the tests. While consistent, many new devs “assumed this is how you do things”, and thus it spread.
It did not matter if a low-level library, a mid-level Platform level class/module, or a UI Component; devs actively avoided Dependency Injection because the cost of stubbing classes/interfaces was too much work.
I did too at first.
Coming from Elm, you quickly learn to do the same thing you do in non-typed languages practicing CICD: small batch changes. In Elm, ReScript, or any good type system, you can quickly overwhelm the compiler and the errors make no sense.
Instead, you keep it compiling, then make 1 small change, and use the compiler error(s) to know how to get back to a compiling state. It’s a lot like TDD or Test After with automated tests.
I found in this code base, TypeScript made your job harder for 3 reasons.
- You had to implement the entire class to be type safe. 13 method class, but only using 1 for your test? Too bad; do all 13.
Partial<T>worked for flattened hierarchies, but nesting? Whip out the any to maybe ship this month.- Libraries using multiple @ type versions.
#3 was quite insidious. The type changes between fetch, node-fetch, undici, and… now official fetch are quite dramatic. That’s just 1 library.
Now imagine many, which don’t support backwards compatibility (:: cough :: Emotion 11).
This results in _some_ stubs/mocks supporting an older version, others the newer. It’s not obvious either, depends on imports. Suffice to say classes can use good abstraction, and still leak type details. Phantom type support isn’t great in TypeScript.
In summary, the compiler made it hard to stub classes and DTO’s. The OOP purists will, rightly, call out the lack of good abstractions (but ignore the types because they didn’t grow up with good types and thus very little literature on good type abstraction vs. class abstraction)
Exacerbated by library authors upgrading @ type dependencies with zero care given to backwards compatibility, and forward ergonomics.
TypeScript compiler errors were to be avoided. Pragmatism towards “just test this 1 method”, enabled by Jest/Vitest’s typesafe spyOn/mock.
The TDD/Test After Purists will argue, rightfully so, “test behavior, not implementation”, but then completely ignore “what.. about… Type Driven Development? What about Effects as Types?” as tools to enhance the tests.
So what would “fix” all this? 2 things:
- functions with a 1 single type to stub
- low-level primitives, abstracted, without leaking types
Doing 1 is easy. Angular, React, and many other frameworks continue to move functional. Stubbing function signatures is… 1 stub. (Caveat: Not all types are easy to work with; React’s are quite not fun).
Now, yes, additional caveats with stubbing fetch (see @ type insanity), but worlds easier stubbing fetch / this.http.get vs. a class or interface with even just 5 methods. The testing frameworks today _enable_ bad behavior with spyOn/mock being type-safe and “just 1 line of code”
So using functions with simple types makes stubbing worlds easier.
However, I’m not really sure how to fix #2. For example, I’ve moved to a 100% back-end team, still TypeScript. Same exact problem; OOP and classes force you to mock the entire class, else Partial<T> or any.
To battle fetch, I abstracted it in an Elm like functional interface: 1 function, 1 input type, 1 output type. MUCH easier to stub, or mock. This worked well until a developer found an upgraded type of an abstraction we use on undici to fetch resulted in a strange compiler error.
He whipped out the any and moved on. Tradeoff there is it’s a well battle tested library, used by hundreds of teams. While any’s aren’t safe, that one was safe to do.
But the only way to fix that is to map the types; the only thing that’d break that is an npm upgrade.
Another option is to use Phantom Types to ensure none of the internal types leak out, but those aren’t very ergonomic to use in TypeScript. Seriously, google it, it’s insane to use in TS; while nice in Elm, it’s still pretty big-brained there.
In short, using functions and types results in much easier to stub, with Dependency Injection, in unit tests. This in turn results in developers more likely to use strong-types instead of any /Partial<T>. OOP encourages bad behavior because the compiler makes classes hard to stub.
Using tools like Jest/Vitest’s spyOn/mock “for easier, type-safe, 1 line of code” as a pragmatic escape hatch leads to bad testing practices.
OOP and Mocks are not your friends.
That said, you should still learn OOP & the tradeoffs so you understand the above.
The tradeoffs in FP are big-brained types, types that are a royal pain to create, and compiler errors that make no sense and make you whip out the as any to get something shipped this century.
Leave a Reply