Emphasizing (behavioral) unit tests

kgajowy
4 min readNov 6, 2022

--

Markers used emphasize important stuff

Have you ever struggled to understand the purpose of the class? Half the trouble if there were any unit tests, right? On the other hand, if the class’s responsibility was hard to follow, tests weren’t easy either — or made you sweat even more to quickly drop a few lines in hopes of rapidly closing the task. Who would bother to understand the spaghetti, right?

Wouldn’t it be a better place, if we could leave clear intentions and purpose of a class existence?

Let’s imagine that together with your colleague Jack, you write a feature that allows a user to buy some item, as long as:

  • there are enough funds in the user’s wallet,
  • there is enough quantity of an object in stock,

“It's easy pew pew”, Jack said. “I will finish it in one hour”, he said. Well, he did!

Be honest — how much did you read? How much did you remember about this class? Do you know what it does? Do you know what is tested?

No worries, me neither. Our cognitive load is limited. For most of us, before we finished reading, we could already forget what was in the beginning. Not to mention that we usually jump back and forth to the implementation class.

“The coverage is almost 100% so we are safe to release”, Jack said. Then you shipped 🚢. Everyone is happy. Curtain up, great success.

Return of the Legacy King

All was good, the feature was working flawlessly. Here comes “the business” man, or angry customer (yes, there is a 🐛). Jack already found a more lucrative job — now you are best friends with Anna. You both can surely do that. Right?

You spend an hour or two on the code and everyone has little confidence if it meets current business expectations, what the class does or why. At least it has tests… but they are not helping. Whatever you touch, it also breaks the test(s).

What went wrong?

The test itself covers the paths but is bloated. With too many setup steps. With too many magic spies and/or mocks. With too many magic values. With too many unclear expectations. How it was intended to work, you ask?

The test (the class issues are a different topic) contains many common issues:

  • bound to the implementation — changing dependency or even the way we call it breaks the test
  • we don’t know the meaning of expect — except ;) the fact we should call some other service
  • it's hard to follow what is the background in the test — there are some values given in the mocks (at least some methods are not get set but reveal intentions!)
  • we can find the possible entry point of our subject under test (calling purchaseService.order(...) ) — but it often happens that we call more things and it becomes hard to understand when something happens
  • we don’t know the desired outcome (or side effects) — in other words, what should happen then

The fellowship of the good developers

Let’s make a few New Year’s resolutions that shall help our future selves and our colleagues:

  • the Class should have a straightforward API and do a single job and delegate everything else (OOP)
  • easily spot in the test the intentions, the outcome, and the required state
  • separate the setup hell from 🍖 and what is important
  • and have the flexibility to change the implementation without touching tests (too much)

Is it a dream? No. Some of you probably already noticed the bolded words Given When Then (or figured it out knowing BDD, Arrange-Act-Assert in C#, from your Acceptance Criteria, User Stories…).

Let’s try to start the test from scratch. Most base, boring boilerplate:

Now the hardest part — model the needs into the prose. In the beginning, we wrote down the requirements, let’s try to write them down.

In fact, it isn’t rocket science. We just named most of the functions into human-readable text. Little effort for so much benefit.

It's worth mentioning that we can write such a test even before we implement anything. What we just have done?

  • declared how we want to use the class (API),
  • what its purpose and what are the side effects,
  • and added code that reflects, more or less, business requirements — we can now change implementation details at our will without altering the core code of this test.

The many towers

To be honest, work so far will probably be most challenging to experienced developers. The rest is… just filling the code gaps (well, maybe apart from defining dependencies and their interfaces!) — in both real implementation and the tests.

The approach for the second may differ, depending on the team. One may prefer to stick to jest.fn() mocks, some may pick in memory implementations (especially for reusable blocks) or mix both up. Let’s see both naive implementations of StockService that is used during order:

StockService we want to use should expose getting available stock for a given item and allow us to retrieve the item (for brevity, we skip the consistency, transactions, and many others).

Of course, the code may be tailored (automocked and stuff). However, please consider another approach that indeed requires some more code but may be useful for more specific scenarios.

Imagine a test in which we want to buy an entire cart instead a single item. Stock mock would need to track all the items. “In memory”-like class could be a handful.

The most important thing to remember — there is no silver bullet. Use the approach that is most relevant to your needs.

What’s Next?

A few more examples and hints can be found in the slides.

Practice is the only way. Discuss with your team members during Code Review if it is clear what the class does and keep in mind that most of the time we are… reading code, not writing ;)

Leave things behind you so no one curses you in pain.

--

--