Subtleties of Software Testing (Plus a Practical Example)

Code testing is now a standard practice in most software projects, or at least I hope it is, and if not, it definitely should be. After all, we already discussed this in the article Best Practices aka How not to be the worst developer. Today, I would like to explore some intricacies that we encountered while testing a frontend application for Kiwi.

How difficult is writing tests?

To this question, I would answer taking a balanced perspective: 'It depends on…' After all, it's also code, and code may not be complex, but it could be. So, what does it depend on, and how complex will writing tests be?

Certainly, it depends on how much you care about tests.

  • Is it sufficient for you to have a large portion of the code base covered, and a green check next to each pipeline satisfies you? Or do you want to really test your application?
  • Do you want the test to truly catch the errors introduced by new code, or should it, on the contrary, break when you modify only the implementation?
  • Do you have tests divided into logical units, or is there a single massive integration test that stretches like jQuery spaghetti, giving you time for 2 coffees before it completes?
  • Is your codebase structured, or is it tangled in a few files?

There could be several reasons why writing tests might be demanding or even tedious. However, let's assume that you understand why you're testing code, you have tests reasonably structured, you view writing tests as part of programming, and your main goal is for tests to help catch unwanted errors, thereby speeding up development and facilitating the work for QA testers. 🙂

With this assumption in mind, let's jump right into a few specific situations where testing can lead you, especially when it comes to frontend applications.

Software Testing in Practice: Tools Are Fundamental

In the project at Kiwi that we are working on, we use standard tools for testing, commonly found in most React projects: Jest and Testing Library. Jest is the foundation, which, while having its quirks, has been thoroughly tested over the years and works well with React. Even better when combined with Testing Library, which I can't imagine testing without today.

Not only does Testing Library provide many handy utilities to streamline frequent actions, but it also encourages the developer to write tests that will test the application as a user would use it. This means testing functionality and not implementation. This might seem obvious, but as developers, we often have a tendency to view the application through our own eyes rather than the eyes of the user for whom the application is intended.

However, such tests may not always be the optimal solution, as you can read in the article Testing Implementation Details by the library's author, along with examples.

How to Mock Different Things Without Going Crazy

While writing integration and unit tests, you will fairly quickly encounter the need to replace a specific implementation with another, so that your test truly only tests the given file or function. The more comprehensive the part of your application that the test covers, the more mocks you will likely need. In the following paragraphs, we will look at situations where mocking becomes a bit more complex.

Mocking Side-Effect Modules

When you need to mock standard exported functionality, you simply use <inline-code>jest.mock('your_module')<inline-code>. But what if you need to test a module that starts doing something right upon import?

I came across such a situation, and its solution is more than straightforward: you just import the respective module at the point where you're testing it using asynchronous import <inline-code>await import('your_module')<inline-code>.

This works for one test scenario, but what if I want to try a different variation in another test? A problem arises because the module has already been imported once and is stored in the module cache. Therefore, on the second import, the side-effect code won't run.

This problem can be addressed by using the <inline-code>jest.resetModules()<inline-code> function in the <inline-code>beforeEach()<inline-code> callback. In this case, however, the programmer must ensure re-mocking and, most importantly, re-importing all the modules. As the function's name suggests, all modules used in the file will indeed be reset. This adds a few lines of code that need to be executed again:

-- CODE language-js -- // logger export const log = (message: string) => { console.log(message) } // logger.test describe("logger", () => { beforeEach(() => { jest.resetModules() // mock all modules again jest.doMock("./") }) test("runs side-effect code", async () => { await import("./logger") // side-effect code runs here }) test("runs side-effect code again", async () => { await import("./logger") // side-effect code runs here }) })

Mocking Side-Effect Modules: Variable Initialized Outside the Function

We will encounter a similar situation when a variable is initialized outside a function. In the example below, we see the definition of a random number assigned to a variable, which is later used within a function. In this case, a conventional mock won't work again, because the variable itself is initialized during import, while mocking occurs later.

However, we can resolve this situation again by using <inline-code>await import<inline-code>, which delays the variable's initialization until after the <inline-code>Math.random<inline-code> function has been mocked.

-- CODE language-js -- // logger const randomControlNumber = Math.random() export const shouldLog = () => { return randomControlNumber < 0.1 } // logger.test describe("logger", () => { test("mocks math", async () => { const spyOnMathRandom = jest.spyOn(global.Math, "random").mockReturnValue(0.1) const { shouldLog } = await import("./logger") expect(shouldLog()).toBe(0.1) }) })

I Need a Partial Mock

If you don't strictly separate your code into individual files, you might easily find yourself in a situation where you need to mock only a part of a module (one function, a class, etc.). Imagine the following file:

-- CODE language-js -- const DataContext = React.createContext(null) export const DataProvider = ({ children }: Props) => { //... return ( {children} ) } export const useData = () => { return React.useContext(DataContext) }

A simple provider that utilizes a hook to wrap a context. In practice, you would use this provider in a parent or directly in the root component, and the hook itself in a lower-level component.

Everything works as it should. Your code is organized, and the separate context, provider, and hook can be easily tested. The problem arises when you need to test a component where you need to mock the hook while keeping the implementation of the provider intact. This could occur, for instance, if you use that provider for test setup in a custom renderer.

This is where the <inline-code>jest.requireActual()<inline-code> function comes in handy, as it returns the original module implementation. It's also useful in other scenarios – for example, when you have a file exporting multiple functions (helpers, utils, etc.), and you want to mock only a specific one.

-- CODE language-js -- jest.mock("../", () => { const originalModule = jest.requireActual("../") return { ...originalModule, useData: jest.fn(), } })

Mocking Asynchronous Code

Sooner or later, you'll certainly encounter the need to include mock asynchronous functions in your code. Jest provides a nice API for this, although sometimes it might not work exactly as you'd expect. I came across an issue while testing side-effect imports.

I wanted to simulate a successful first call and an unsuccessful second call. I wrote the code that should work as follows:

<inline-code>jest.fn().mockResolvedValueOnce({your:'value'}).mockRejectedValue(...)<inline-code>

Unfortunately, in this combination, the mock doesn't work quite as expected, and I had to split these two variations into separate tests.

A Few Useful Tools for More Successful Software Testing

isolateModules and isolateModulesAsync

  • If you need to test a module that's imported multiple times, these functions can come in handy. They create a sandbox environment in your test, ensuring that individual imports do not interfere with each other.

Testing Multiple Scenarios

  • Fairly recently, I discovered the each functionality, which allows you to execute a given test or a whole suite for multiple inputs at once.

Extending Matching Functions

  • An interesting extension for Jest is the library jest-extended. It offers several functions that you'll use on a daily basis when writing tests, saving you a few keystrokes.

We've delved into one category of testing that might give you complications. I hope you'll find at least some of these tips useful when testing your applications, thus reducing the number of bugs in production. Wishing you lots of caught errors and well-deserved green checkmarks in your pipeline!

Subtleties of Software Testing (Plus a Practical Example)
Ondřej Hajný
Frontend Developer
By clicking “Accept”, you agree to the storing of cookies on your device to enhance site navigation, analyze site usage, and assist in our marketing efforts. View our Privacy Policy for more information.