Angular TestBed considered harmful!
Why I don’t use Angular TestBed and perhaps you shouldn’t either
In this article, I will do a brief overview of TestBed, talk about some of its properties and practices, and then offer an alternative. An alternative that gives you back control over your tests! And a test-automation option!
Let’s make the state of the component our interface with Angular and let it render HTML and do DOM.
I need to only test MY code.
During my practice, I’ve found using TestBed to be sub-optimal and have developed a strategy that works really well for unit testing components, services, and any other Angular and non-Angular abstractions.
What does TestBed do for us?
- It creates a dependency injection (DI) context and allows us to override providers, services, and whole modules. It compiles, instantiates, and renders to HTML our components attaching them to the fixture instance.
- It wants to compile and instantiate our components and then execute tests against the DOM. This forces us, the test authors and maintainers to query DOM and work with the HTML which is the output of Angular. I find that slow, quirky, and most importantly — not our job. I trust Angular that when given a variable that is
visible === true
to actually visualize my component: - And hide it when
visible === false
. And I should not test that. That's Angular's job and I trust it and its thousands of tests. There are thousands of people looking on the framework's code, chances are bugs will get caught and fixed. I need to test MY code. That's what is usually broken because there are only one pair of eyes on it and zero unit tests until I actually create them.
What I’m getting at is test only our code and let Angular do its job. That is to take the values and bind them to HTML. Which means we do not need to actually compile our components and have TestBed render them. Only to instantiate the underlying class and test the instance’s methods and properties.
Taking the approach of not rendering HTML coerces us into not having logic in the templates because we would not be able to test it! And not having logic in templates is a very nice thing!
TestBed is not required
If we do not need to compile, instantiate, and render our components we do not require TestBed. But its API is convenient with the TestBed.get
or inject
function - right? It allows us to override modules. And so on. Kinda is, kinda is not because we then need to fill in the prerequisites to our test case inside our test case. And it often looks like this:
So this looks like a normal test case — right? It looks like many test cases I wrote myself. And while it does the job of making sure the upcoming book tour is visible there are a few things it does not answer when it should.
- What is the prerequisite? — Having a book? Having
bookTours
? both? Having a name for the author? - What are we testing for here? There’s a lot of clutter.
- Would we need to edit all
Author
instantiations if the shape ofAuthor
class changes? Every test on the author component would need one - right? - Where do
fixture
anddebugElement
come from? For a newbie in the Angular unit testing that is hard.
What if we rewrote the test to look like this:
There are a few things going on here:
- The test case spells out the prerequisites in English and delegates the instantiation logic, clearing the stuff we, as the test reader, do not need for understanding the test case logic. Thus making it more readable and more maintainable. It often adds the value of conciseness to the test case: fewer lines to read and comprehend. At the same time, everything the case requires is explicit. No need to go to the
beforeEach
orasync beforeEach
to find out what the setup looks like.
— You, the Reader, can better judge readability here. - It does not instantiate and render our component. Just tests out the state of the component. No DOM, quicker and less flakier test.
- It gives us one place where we control the context of each test case and lets us decide the exact moment of instantiation of our unit under test.
Setup
Introducing the setup
function. It works to replace the whole TestBed test workflow. It will take the task to create dependencies and instantiate the class under test.
It takes the place of getMyComponent()
in the example above. Using autoSpy
which takes away the need to create mock objects manually:
- We’ve named the methods of our builder object so that they tell the business use case and hide the implementation. A reader can still go and understand HOW a prerequisite is set up, but they also know WHAT it is setting up. The reader gets one more piece of our context, as unit test authors. This is very important in making the test more maintainable.
- The lengthy implementation is out of sight in the test case itself — one more thing improving readability.
- We are using the builder pattern here for better developer ergonomics. It seems that the dot-chaining is the preferred way of consuming APIs.
- There is one place to control all of our setup in. In the future, where things often change, we would need to change stuff in our
setup
function only and not all over our test cases. - The
setup
function does not know how to render HTML! But - "How can I test the logic without dabbling in the DOM and HTML?" By making the state of the component our interface with Angular and let it render HTML and do DOM manipulation. Angular is so good at doing those things and we need not do them :) - The mock logic placed in
setup
we already have to place somewhere, even when using TestBed. And having the builder gives us that place. Instead of placing those mocks in the test cases themselves.
Using dependencies directly
There are cases where we might want to manipulate the dependencies directly. We might use TestBed.get
or inject
function for that. The setup
equivalent to that is destructuring. Continuing the example above, in our test case we might destructure and get the service
:
Usually, I would encourage these setting up activities to be wrapped in a setup
method, but the world is a messy place and sometimes we just need to work directly with our dependencies.
Default
Often there are some initialization tasks before we can consider a unit working. For example — instantiate observables, fill in required state, etc. The result is a base line, a working component that we know will not throw when we call ngOnInit
on it. These tasks I put in a dedicated default
method.
If we had a BooksComponent
that depended on BooksService
like this:
We would need to make sure that getBooks
returns some observable, otherwise we'll get an exception.
Why a separate method, you ask? Why not part of the build
for example? Well, the answer is trivial - we might need to actually test the case where the exception is thrown and for that we'd like to not call default
, so having it gives us that option.
Why “setup”?
In the first example, I was testing a component so that’s what I initially went with — getMyComponent
. We also test services and pipes etc.
Also, I wanted to automate the maintenance of tests and it’s easier to discover the function when it’s called setup.
Team acceptance
This looks like magic!
This will never work!
Yes, I realize its a totally different thing than what you may be used to. I realize that fact will make the setup
function seem alien, complex and hazardous. "Looks like magic!" A colleague of mine Симон Казаков told me when he first saw it.
I would understand it if you don’t want to jeopardize your team’s workflow.
And of course, I would be glad if you tried it and gave me some feedback.
Compatibility
Oh, and this also works side by side with TestBed
. You can use both or rely on setup
for getting the dependencies for the test module.
Automation
Having the setup
function allows for a bit of automation. I've created a tool ( SCuri) and a VS Code extension for it, that makes creating and updating unit tests a breeze. It removes the boilerplate part of doing unit tests and leaves you with the part that only you can do. Boilerplate being:
- generate the
setup
function and thebuild
method - update the
setup
function and thebuild
method with new dependencies - add a test case for each public method of your class
- generate
autoSpy
for jest/jasmine
Thanks for reading!
I’ve been doing a lot of unit tests, and this workflow has proven a better experience. Try it and please tell me what you think.