3 Practical Tips for Everlasting Tests
In many cases, when I review code or mentor others, I am asked what and how to test a certain functionality. This last week, the debate was hot regarding a new piece of code I was reviewing. So - here's the story and some practical advice on how to write better tests that will change less often and allow you to sleep better.
What Are We Testing?
We have a popup component. This internal component takes care of positioning a popup. While not exposed to consumers, it is used in other more complex components, such as menu, combobox, date picker and more. It can be officially termed as a Facade.
We need to add a new feature to the popup. Here are the specifications:
It was defined in the class like this:
@attr({ mode: 'boolean', attribute: 'animation-frame' }) animationFrame =
false;
animationFrameChanged() {
this.#updateAutoUpdate();
}
#updateAutoUpdate() {
this.#cleanup?.();
if (this.anchorEl && this.open && this.popupEl) {
this.#cleanup = autoUpdate(
this.anchorEl,
this.popupEl,
() => this.updatePosition(),
{
animationFrame: this.animationFrame,
}
);
}
}
Defining animationFrame is easy. animationFrameChanged calls an update function whenever we update the property. The private method updateAutoUpdate does the following:
Pretty simple. Now, how do we test this feature?
Testing, Mocking, and Coupling
One way to test it would be to mock autoUpdate and make sure it is called with the right parameters:
describe('animationFrame', () => {
beforeEach(() => {
jest.spyOn(floatingUI, 'autoUpdate');
});
afterEach(() => {
jest.mocked(floatingUI.autoUpdate).mockRestore();
});
it.each([false, true])(
'should pass animationFrame=%s as autoUpdate option',
async (animationFrame) => {
element.animationFrame = animationFrame;
await setupPopupToOpenWithAnchor();
await elementUpdated(element);
expect(floatingUI.autoUpdate).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
{ animationFrame }
);
}
);
});
In this test, we stub autoUpdate from floatingUI before every test. In the test itself, we change animationFrame's value and expect this value to be sent to autoUpdate.
This is not a bad test in itself. It's really fast to write. But are we losing something when we test this way?
The Price of Mocking
When we mock the 3rd party library this way, we pay a certain price:
The clearest outcome of this is - if we decide to replace floatingUI, would we be able to know if our component is still functioning correctly? No, we would not. We would need to change the test again, even though the interface of popup remained the same.
The maybe-less-clear outcome is a test that doesn't follow the test consumer/user flow. The user doesn't care about implementation detail, whether you have a watcher with a third-party library or built it yourself.
Testing Your Interface
The other way to test would be to test your interface as if the 3rd party library does not exist. In other words - test it like a consumer of the component would use it.
领英推荐
Looking back at our specifications:
The first test is easy - we need to make sure we have an animationFrame property with a default value:
it('should be false by default', () => {
expect(element.animationFrame).toBe(false);
});
Now we want to test that it doesn't run the watcher when false:
it('should disable recursive calls to requestAnimationFrame when false', async () => {
await openPopup();
const cb = getLastFrameCallback();
rAFStub.mockReset();
cb();
expect(rAFStub).toHaveBeenCalledTimes(0);
});
And of course, test that it runs the watcher when true:
it('should call rAF recursively when true', async () => {
element.animationFrame = true;
await openPopup();
const cb = getLastFrameCallback();
rAFStub.mockReset();
cb();
cb();
expect(rAFStub).toHaveBeenCalledTimes(2);
expect(getLastFrameCallback()).toBe(cb);
});
One might think we are done - but no. Our spec definitely claims that we should not call the update function if the size or position doesn't change:
it("should prevent call to updatePosition if position or size didn't change", async () => {
setElementClientRect({ width: 100, top: 100 });
element.animationFrame = true;
await openPopup();
resetMethodCallCount('updatePosition');
callLastFrameCallback();
expect(element.updatePosition).toBeCalledTimes(0);
});
The last things to check are initiating an update when size or position do change:
it('should updatePosition if size changes', async () => {
setElementClientRect({ width: 300 });
element.animationFrame = true;
await openPopup();
resetMethodCallCount('updatePosition');
setElementClientRect({ width: 400 });
callLastFrameCallback();
expect(element.updatePosition).toBeCalledTimes(1);
});
it('should updatePosition on next frame if position changes', async () => {
setElementClientRect({ top: 100 });
element.animationFrame = true;
await openPopup();
resetMethodCallCount('updatePosition');
setElementClientRect({ top: 200 });
callLastFrameCallback();
expect(element.updatePosition).toBeCalledTimes(1);
});
That's a lot of testing for something we covered with just one mock, right?
And here comes the hard truth - bigger gains take more time and effort.
We gained decoupling (which always sounds good, right?). We are no longer coupled with floatingUI.
In addition, we gained the confidence to make changes - we can remove floatingUI altoghether and we'll know if our replacement did the trick. We don't even need to replace it - just upgrade a version of the library that might change the interface by accident (it's called a bug ;) ). Our component will catch it, and we can do that weekend deployment we always dream of with a safety net.
So... How do I write them better tests? (A.K.A. Summary)
We saw a few principles in this example. I'll start from the most beneficial in this case:
What do you think? Do these benefits worth the effort?
Senior .Net Developer Advocate | Speaker | Coach | Mentor
5 个月The "price to pay" is an investment, which will allow you to go faster in the future. It's totally worth it IMO.