7 min read

How I improved my unit tests

How I improved my unit tests

Hello, welcome to a new post!

Today I want to talk about a very important topic that should never be missing in serious software development: unit tests.

Unit tests are code we write to validate the logic of our program. These tests are characterized by evaluating small parts of the code, such as a function or a class.

Additionally, unit tests are very useful when refactoring code in the future, as they help ensure that what previously worked correctly continues to do so.

And really, I think we've all worked on a project where the team is afraid to change certain parts of the code, because we don't know if the changes will affect previously implemented business rules, causing strange behavior and those dreaded bugs.

The goal of these tests is to give you confidence in what you write and help you deliver as few bugs as possible. However, sometimes projects already have unit tests—maybe even an extensive suite—but simply having them doesn't guarantee you're covered. This happens when tests are outdated or have just been patched with each change, written only to make them pass and keep the CI process quiet, allowing work to continue without interruption.

This is a clear symptom that your unit tests have become a burden, and the whole team sees them that way. Writing tests becomes a tedious task with no reward. And in these cases, it's true: the tests just slow down development.

We can avoid this situation by changing our perspective: unit tests are a part of the system and must be treated with the same care as the main codebase.

Just because they don’t run in production doesn’t mean they should be forgotten after being written once. They must be maintained attentively, updated consciously—not just to keep the report green. For example, if you delete a method or class, you should remove the related tests. If a business rule changes, it's normal for tests to fail at first—but they must be updated to reflect the new logic. Don’t hide the real problem with hacks to make the test pass, and definitely don’t comment it out or delete it.

Since unit tests are so important, here are some tips I've learned that can help you improve them and feel more confident in your code.

Disclaimer: Unit tests alone don’t cover the whole system. It’s important to complement them with other types of tests like E2E, manual QA tests (if you have a QA team), and more. Still, unit tests are our first line of defense when making changes.

Tips

  1. Tools
  2. How to name your subject under test
  3. Write a clear message for your test
  4. How to avoid test fragility
  5. What are mocks and when to use them
  6. How much code coverage should I aim for?

Tools

In these examples, I'll use JavaScript, but the concepts are language-agnostic. JavaScript is just a tool to communicate the ideas.

JavaScript has popular libraries you can use to create tests. But first, it’s important to understand the difference between a Test Runner and an Assertion Library.

A Test Runner provides the infrastructure to execute tests in isolation (sometimes even in parallel), along with debugging utilities and more. Examples include:

  • Jest
  • Mocha

Assertion Libraries may be included with the test runner (as with Jest) or installed separately. Examples include:

  • Chai
  • Assert Module (built-in with Node.js ≥ v18)

These tools give you functions to validate whatever you need: an object’s content, a date, a string, etc.

Here’s an example:

const assert = require('node:assert/strict');

assert.deepEqual([[[1, 2, 3]], 4, 5], [[[1, 2, '3']], 4, 5]);
// AssertionError: Expected inputs to be strictly deep-equal:
// + actual - expected ...
// +     3
// -     '3'
// Example from Nodejs Docs https://nodejs.org/docs/latest-v22.x/api/assert.html#assert

In this example, we expect both arrays to have the same elements.

You might ask, "Do I really need these libraries if I can do it manually?" And the answer is: yes and no. You could write your own validation methods, or you could save time by using a library that works out of the box.

To get started, you’ll need to know how to configure these tools. It’s not difficult—the documentation is usually more than enough. Personally, I use Jest in my projects because it comes by default with NestJS, and it includes an assertion library.

How to Name Your Subject Under Test

The subject under test is often referred to as the SUT (System Under Test). I liked this naming convention when I read about it in the book Unit Testing, and I’ve adopted it ever since. It makes it clear what component is being tested and avoids confusion with other variables in the test.

Of course, this is just a personal preference. You’re free to name it however you like, but always try to make it obvious which part of the system you’re testing.

// sum.js
class Calculator {
  sum(x, y) {
    return x + y;
  }
}

// sum.spec.js
describe('Calculator', () => {
  let SUT; // System Under Test

  beforeEach(() => {
    SUT = new Calculator();
  });

  afterEach(() => {});
});

Write a Clear Message for Your Test

class EmailSender {
  send(to, content) {
    // validations
    this.provider.send(to, content);
  }
}

describe('EmailSender', () => {
  let SUT;

  beforeEach(() => {
    SUT = new EmailSender();
  });

  afterEach(() => {});

  // ❌ Not descriptive
  it('test method send()', () => {});

  // ✅ Clear descriptions
  it('send(): fails with invalid email', () => {});
  it('send(): fails with invalid content', () => {});
});

In the example above, we see that we can name our tests however we want, but in the end, we’re the ones affected by poorly described messages—either us or our teammates.

Imagine someone new joins the project, changes the EmailSender class, and a test fails. The only message shown is: test method send(). You’d pull your hair out trying to understand what it does and how to fix it.

On the other hand, if tests are clearly described—as in the later examples—you quickly get an idea of what might have failed, making the debugging experience much more pleasant.

How to Avoid Fragility in Your Tests

There’s an approach that can greatly improve your tests and help avoid fragility, false positives, and difficulties when refactoring.

Often, writing tests reveals how well we’ve applied best practices. This is because when we test our code, we should aim to isolate that unit as much as possible from the rest of the system.

What this means is: if your function or class relies on external resources like APIs or other classes—and you depend on concrete implementations instead of abstractions—you’re introducing fragility. Any updates to those dependencies might cause your tests to fail for reasons unrelated to the logic you’re actually testing. These false positives, if not addressed, make tests unsustainable.

If you depend on abstractions instead, testing a component with dependencies becomes much simpler. You can replace those dependencies easily and focus on what really matters: your business rules, algorithms, and logic.

// BAD: Depends on a concrete class
class EmailSender {
  constructor() {
    this.provider = new SNSAWSService(); // concrete class dependency
  }

  send(to, content) {
    // validations
    this.SNSServiceAws.send(to, content);
  }
}

// GOOD: Uses dependency inversion, easier to test
class EmailSender {
  constructor(provider) {
    this.provider = provider; // abstraction
  }

  send(to, content) {
    // validations
    this.provider.send(to, content);
  }
}

We can solve this by using dependency injection and applying the SOLID principle of Dependency Inversion, which tells us to depend on abstractions—in this case, an interface.

Another thing that helped me a lot is shifting my perspective: I now focus on testing what I want the code to do, the outcome, instead of worrying about how it’s done.

This change makes a big difference. It allows you to ignore implementation details (like exact algorithm steps) and focus on verifying that the desired result is correct. When you reflect that in your tests, you achieve greater robustness and resilience to change.

Don’t waste time testing implementation details that may change. Your goal is to ensure the system behaves as expected from a functional perspective. This leads to more stable and meaningful tests in the long term.

What Are Mocks and When to Use Them?

Mocks are, colloquially, functions or classes that replace real dependencies in our tests. Their purpose is to help isolate the code and prevent the use of real services.

Imagine you need to send an email, an SMS, etc. A good practice would be to avoid using the real services for several reasons: cost, accidentally notifying real users, and because it’s much easier to replace them to have full control over what happens in your code.

Without mocks, tests would be too unpredictable, since they would depend on external components.

Jest provides built-in methods to create mocks and spies.

The difference between them is that spies allow you to track calls made to the mock—you can check what arguments were passed, how many times it was called, in what order, and so on.

One Important Note Before the Example:

You don’t always need to make assertions on your mocks.

This is because, in many cases, it's not relevant to the logic you're testing. Also, making unnecessary assertions can introduce fragility into your tests, since mocks simulate external dependencies that may change over time.

However, it does make sense to assert on mocks when they are relevant to business rules.

For example: if your application must send an email when a user is created, it's important to verify that the email service was called—and with the correct data.

This helps you confirm that your code is truly executing the key actions that matter to your business.

Now, let’s look at an example:

// notification.test.js
import * as emailService from './emailService.js';
import { notifyNewUser } from './notification.js';

beforeEach(() => {
  // Restore original behavior
  spy.mockRestore();
});

it('notifyNewUser: sends a welcome email', async () => {
  // Spy on sendEmail and prevent real call
  const spy = jest
    .spyOn(emailService, 'sendEmail')
    .mockResolvedValue('spy-ok');

  const user   = { email: '[email protected]', name: 'Axel' };
  const SUT = await notifyNewUser(user);

  // Verify parameters and return value
  expect(spy).toHaveBeenCalledWith(
    '[email protected]',
    'Welcome!',
    'Hi Axel, thanks for joining.'
  );
  
  expect(result).toBe('spy-ok');
});

Code Coverage

Lastly, when it comes to code coverage, I believe having a test suite that covers around 80% of your code is a very good goal.

But we must keep in mind that setting a number shouldn't distract us from what truly matters: taking care of the quality of our tests.

It's not about hitting or exceeding a specific percentage—it's about writing meaningful tests. And as a natural consequence of doing so, you'll likely hit that coverage mark anyway, or get very close to it.

It’s also important to listen to the team, understand their perspective, and find a way for everyone to feel comfortable. Because when a strict percentage is enforced, the team may focus solely on reaching it, writing tests that add little to no value.


Conclusion

If you’ve made it this far, I hope you’ve taken something useful that you can start applying right away.

Writing good unit tests takes practice and consistency.

Personally, I’m still learning how to improve my own tests, but applying the principles I shared here has definitely helped me improve the quality of my code and avoid many false positives.

If you have any feedback or want to connect, feel free to reach out on LinkedIn or X (Twitter) as @im_not_ajscoder.