This post is not about what unit tests are or why software developers should create those. Instead I would like to focus on what I see on a daily basis working in a big IT company in 2025. And how we could enhance an approach of writing unit tests.

Why to create unit tests at all?

Even though I've mentioned that this post is not about "why unit tests" I'd like to start with a couple of ideas on an actual "why" before we move on. Abstractly speaking, unit tests are there to verify a behaviour of a certain part of your system based on sufficient input data. How would that system react to all possible input types that you might operate with. Why? Because being able to predict a behaviour of each part of the system separately allows us to verify that it's working properly as a whole thing.

Tests coverage

Once there is a piece of code it is necessary to write some unit tests for it, i.e. to assess it with another piece of code. Code that would automatically control the outcome of software engineering sounds like a great plan. The question is how do we know that we don't need yet more control? That means how do we know there is not an "if clause" or a "for loop" or you name it anywhere in the code that's left unseen?

A simple tests coverage approach based on the lines number (not the only one though) could help us there. And this is the most frequently used pattern of measuring our unit tests success story I have seen so far. Getting 100% of coverage sounds like a dream and that's what a lot of software developers are trying to achieve. Is it enough? Do they miss anything?

Example

Let's take a look at an example written in TypeScript. We need to solve a simple problem of getting the smallest number by division. Suppose there is a function called getSmallestByDivision that would accept two arguments: dividend and the list of numbers. Our function should return the smallest quotient amongst all possible items of the list being a divisor. In case the list is empty we would return dividend. If we have dividend = 10 and the list of [1, 10, -1, 1000] then the answer will be -10 because 10/-1 is the smallest quotient amongst all.

function getSmallestByDivision(dividend: number, divisors: Array<number>): number { let res = Number.POSITIVE_INFINITY; for (let i = 0; i < divisors.length; ++i) { res = Math.min(res, dividend / divisors[i]); } return res === Number.POSITIVE_INFINITY ? dividend : res; }

Our goal is to write unit tests for this function. In most of the cases software developers in 2025 would come up with the following:

assert.strictEqual(getSmallestByDivision(10, [1, 10, -1, 1000]), -10); assert.strictEqual(getSmallestByDivision(10, [1, 2, 3, 5]), 2); assert.strictEqual(getSmallestByDivision(10, []), 10);

The reasons behind this approach:

  • 100% of tests coverage achieved;
  • unit tests successfully created and unblocked github ci pipeline for a merge;
  • having TypeScript would help us make sure that we're dealing with the numbers only;

Unit tests that we miss

If we go back to the beginning of this post and remember why to create unit tests at all then one question will be asked. Is the data that's been used in the example sufficient? A short answer is no. But before looking into what else might have been missing there let's bust one myth about using TypeScript in your codebase. It does exist but as long as your code does not go to production. Because even though it's 2025 we still deploy plain JavaScript code to our users. And JavaScript is what's running in a browser. And JavaScript in 2025 is a dynamically typed language.

Let's assess the missing parts next. What if our list of numbers contained zero? Would it break anything or throw an exception? The answer is no. But the question is do we know how getSmallestByDivision would work in that case?

  • What to expect?
  • Do we even want to divide by zero?
  • Should we skip it?

Some popular arguments against asking the questions above in 2025:

  • I talked to a couple of colleagues and they told me that we would never come up with a zero in the list;
  • our customers don't usually add zeros to such a list (let's say a list of prices in their case);

Let's try to contradict these but before...

Imagine you planned a vacation trip to the Caribbean sea. You just boarded on a plane, took a sit in the first class and heard the captain speaking to a co-pilot assistant.

— "There might be the case that we would have to go down to the point of 18.000 feet somewhere over the middle of Atlantic ocean, sir," said the co-pilot assistant, "due to the weather condition, I've checked the last forecasts".

— "I've never flown a plane like this at that specific height over the Atlantic, Jack," replied back the captain.

— "Sir, I believe we could cope with that since I personally know two other captains that flew a similar aircraft at 20.000 feet over the ocean and no incidents," said Jack.

— "Sounds great, Jack! And maybe we woundn't even have to do that at all," said the captain, "you know, all these forecasts..."

Are you still flying for vacation?

When software developers work on a project in a small group of people then there is a chance that they would end up with a consent of never passing a list with zeros to getSmallestByDivision as its second argument. And we also should assume that such a project is not big. But even then it's worth considering a backend API which responds back with data which in turn could provide a list of numbers with zeros inside just because some backend developers followed the same pattern of applying unit tests or else. Even if they did not your frontend team would have to deal with the backend engineers in a way that their response should not affect the getSmallestByDivision function by providing the list of zeros back with some json structure.

  • What if there is more than one frontend team working on a project?
  • What if your codebase interacts with third-party libraries that your team would rely on as well?
  • And what if your changes are playing the role of third-parties for some other teams in a company that they would rely on?

Eventually all of this will end up with the dialog between the captain and Jack co-pilot above, i.e. not being able to rely or control the bigger picture — the product many people keep working on.

When lots of JavaScript code runs on production (third-parties, frameworks and thousands of components interacting with each other) how would you verify or prove that getSmallestByDivision will never recieve the list of numbers with a zero inside (and not only this)?

You can not. Because the complexity is too high. But you can write the tests to verify a behaviour of a certain part of your system based on sufficient input data.

Revamp the unit tests

Let's add another test case for our function based on the assumption that we would ignore division by zero.

assert.strictEqual(getSmallestByDivision(10, [1, 10, -1, 0, -2]), -10);

Not only we've just added another assertation that makes our code more predictable but also found an issue in initial implementation. Because the last assertion fails so far.

function getSmallestByDivision(dividend: number, divisors: Array<number>): number { let res = Number.POSITIVE_INFINITY; for (let i = 0; i < divisors.length; ++i) { if (divisors[i] === 0) { continue; // ignore division by zero; } res = Math.min(res, dividend / divisors[i]); } return res === Number.POSITIVE_INFINITY ? dividend : res; }

Code snippet above should fix it. Are we good so far? No.

Since JavaScript is dynamically typed language it's absolutely legal to pass other types of data besides numbers to getSmallestByDivision. As mentioned above it might be crucial to understand how our function would react to those. The following data types are defined in the language so far:

  • number
  • boolean
  • string
  • object
  • function
  • undefined
  • symbol

How should our function work if dividend becomes any of these types expect for number? Let's say it should throw an exception since it's uncertain how to convert it to a number in our case to be able to divide further. After introducing a set of assertions for such cases we could end up with the following:

assert.throws(getSmallestByDivision(true, [1, 2, 3])); // boolean assert.throws(getSmallestByDivision('', [1, 2, 3])); // string assert.throws(getSmallestByDivision({}, [1, 2, 3])); // object assert.throws(getSmallestByDivision(function() {}, [1, 2, 3])); // function assert.throws(getSmallestByDivision(void 0, [1, 2, 3])); // undefined assert.throws(getSmallestByDivision(Symbol('abc'), [1, 2, 3])); // symbol assert.throws(getSmallestByDivision(null, [1, 2, 3])); // null is object in JS; assert.throws(getSmallestByDivision(NaN, [1, 2, 3])); // NaN is number in JS;

To let the tests pass we have to modify our function as well.

function getSmallestByDivision(dividend: number, divisors: Array<number>): number { if (typeof dividend != 'number' || isNaN(dividend)) { throw new Error('getSmallestByDivision: unsupported type of dividend, expected a number.'); } let res = Number.POSITIVE_INFINITY; for (let i = 0; i < divisors.length; ++i) { if (divisors[i] === 0) { continue; // ignore division by zero; } res = Math.min(res, dividend / divisors[i]); } return res === Number.POSITIVE_INFINITY ? dividend : res; }
  • What about the second argument?
  • What if the list of divisors contained data types apart from a number as well?

Let's assume that we could skip that kind of an item just like we've handled a zero case otherwise it would lead to an uncertainty behind type casting: should a function become one? Or maybe 10? Same for other irrelevant types in our case.

assert.strictEqual(getSmallestByDivision(10, [ true, '', {}, function() {}, void 0, Symbol('abc'), null, NaN, 2.5, 3, -1, -1.0 ]), -10);

Let's apply the changes to our function.

function getSmallestByDivision(dividend: number, divisors: Array<number>): number { if (typeof dividend != 'number' || isNaN(dividend)) { throw new Error('getSmallestByDivision: unsupported type of dividend, expected a number.'); } let res = Number.POSITIVE_INFINITY; for (let i = 0; i < divisors.length; ++i) { if (typeof divisors[i] != 'number' || isNaN(divisors[i]) || divisors[i] === 0) { continue; // ignore division by zero as well as data types other than a number and NaN; } res = Math.min(res, dividend / divisors[i]); } return res === Number.POSITIVE_INFINITY ? dividend : res; }

Are we good so far? No. But we're getting closer.

Since JavaScript is a dynamically typed language we could invoke our function without arguments at all. A call like this getSmallestByDivision() is legal.

  • What should happen in that case?
  • How would we handle it?

The function expects two arguments. And the call above would work as we expected, i.e. throws an exception since dividend is not a number.

assert.throws(getSmallestByDivision()); // no arguments, it throws an exception;

But what about the case of passing a valid argument for dividend but omitting the second argument? It will throw as well. But the difference is that we would not expect it. To make it predictable and robust we could finally treat such a case as an empty array. That would make sense because it's impossible to iterate over nothing. And here comes the test case.

assert.strictEqual(getSmallestByDivision(10), 10); // second argument is omitted;

To make it even more reliable we could check if the second argument is an array. And if it's not then treating divisors as an empty array would be our choice.

Here come missing tests:

assert.strictEqual(getSmallestByDivision(10), 10); // second argument is omitted, i.e. undefined; assert.strictEqual(getSmallestByDivision(10, null), 10); // second argument is null; assert.strictEqual(getSmallestByDivision(10, true), 10); // second argument is boolean; assert.strictEqual(getSmallestByDivision(10, ''), 10); // second argument is string; assert.strictEqual(getSmallestByDivision(10, Symbol('abc')), 10); // second argument is symbol; assert.strictEqual(getSmallestByDivision(10, function() {}), 10); // second argument is function; assert.strictEqual(getSmallestByDivision(10, {}), 10); // second argument is object; assert.strictEqual(getSmallestByDivision(10, NaN), 10); // second argument is NaN;

The final implementation:

function getSmallestByDivision(dividend: number, divisors: Array<number>): number { if (typeof dividend != 'number' || isNaN(dividend)) { throw new Error('getSmallestByDivision: unsupported type of dividend, expected a number.'); } if (!Array.isArray(divisors)) { return dividend; } let res = Number.POSITIVE_INFINITY; for (let i = 0; i < divisors.length; ++i) { if (typeof divisors[i] != 'number' || isNaN(divisors[i]) || divisors[i] === 0) { continue; // ignore division by zero as well as data types other than a number and NaN; } res = Math.min(res, dividend / divisors[i]); } return res === Number.POSITIVE_INFINITY ? dividend : res; }

Summary

There are many tools and frameworks in 2025 that help us build better solutions with less efforts. Software developers today can be much more productive than 10 or 15 years ago. At the same time with this variety of tools they tend to forget about the basic idea behind software development in general:

  • robustness
  • scalability
  • maintenance

Let's keep in mind that any complex and properly working software is just a set of thoroughly designed and tested components. And let's create our unit tests not only for tests coverage itself but also for being able to predict the workflow of what's being tested. To do that we just need to ask ourselves more questions "Why?" which will eventually be worth it.