Engineering

Subtleties of Software Testing (Plus a Practical Example)

Ondřej Hajný
Ondřej Hajný 8 min read

Code testing is now standard in most software projects. At least, I hope it is, and if not, it definitely should be. We’ve already covered this in the article ‘Best Practices aka How not to be the worst developer.’ Today I’d like to examine some pitfalls we encountered when testing a frontend application for Kiwi.

How difficult is writing tests?

To this question, I would answer diplomatically: ’It depends…’ After all, it’s code, and code doesn’t have to be complicated, but it can be. So what does the difficulty of writing tests depend on?

  • Do you just need high code coverage and a green checkmark in each pipeline to consider it done, or do you want genuine code testing?
  • Do you want tests to truly catch errors introduced by new code, or should they break when you modify just the implementation?
  • Are your tests organized into logical units, or do you have one massive integration test that runs like jQuery spaghetti while you finish two coffees?
  • Do you have a structured codebase, or is it sprawled across a few files?

There can be several reasons why writing tests might be difficult or even frustrating. However, we’ll assume you understand why you test code, your tests are reasonably structured, you treat test writing as part of programming, and your primary goal is helping catch unwanted errors to accelerate development and ease QA work.

Software Testing in Practice: Tools Are Fundamental

For the Kiwi project we’re working on, we use standard tools found in most React projects: Jest and Testing Library. Jest is the foundation — it has its quirks but is battle-tested and works well with React. Even better when combined with Testing Library, which I can’t imagine testing without.

Testing Library not only provides numerous utilities simplifying common actions, but mainly pushes developers toward writing tests that lead to testing applications as users will use them. This means testing functionality, not implementation. This might seem obvious, but as developers, we often tend to view applications through our own lens rather than the user’s perspective.

Such tests might not always be a happy solution — you can read why with examples in ‘Testing Implementation Details’ by the library’s author.

How to Mock Different Things Without Going Crazy

When writing integration and unit tests, you’ll quickly need to replace an implementation with another so your test truly tests only the given file or function. The broader your test covers, the more mocks you’ll likely need. The following sections examine situations where mocking becomes complicated.

Mocking Side-Effect Modules

When you need to mock standard exported functionality, you simply use jest.mock(‘your_module’). But what if you need to test a module that starts doing something upon import? The solution is quite simple: import the module asynchronously using await import(‘your_module’) where you test it.

This works for one test scenario, but what if you want to try another variant in the next test? A problem arises because the module is already imported once and stored in the module cache. The side-effect code won’t run on the second import. This can be solved using jest.resetModules() in a beforeEach() callback — though the programmer must ensure re-mocking and re-importing all modules.

// 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 face similar challenges if a variable initializes outside a function. In the example below, a random number is defined in a variable used later in a function. Classic mocking won’t work because the variable initializes during import, while mocking happens afterward. We can solve this using await import, which delays variable initialization until after mocking Math.random.

// 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 code into individual files, you can easily encounter situations where you need to mock only part of a module. Imagine a context provider file that exports both a DataProvider and a useData hook. Problems arise when you need to mock the hook while keeping the provider’s implementation — for example, if you use the provider during test setup in a custom renderer.

const DataContext = React.createContext(null)

export const DataProvider = ({ children }: Props) => {
  //...
  return (
    <DataContext.Provider value={...}>
      {children}
    </DataContext.Provider>
  )
}

export const useData = () => {
  return React.useContext(DataContext)
}

Here, the jest.requireActual() function proves helpful — it returns the original module implementation. It’s useful when you have a file exporting multiple functions and want to mock only some:

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

Mocking Asynchronous Code

Sooner or later, you’ll encounter the need to include a mock async function in your code. Jest has a nice API for this, but sometimes it might not work as you’d expect. I encountered this problem when testing side-effect imports — I wanted to simulate the first call succeeding and the second failing using jest.fn().mockResolvedValueOnce({your:‘value’}).mockRejectedValue(…). Unfortunately, such a combination doesn’t work at all, and I was forced to split these two variants into separate tests.

A Few Useful Tools for More Successful Software Testing

isolateModules and isolateModulesAsync

If you need to test an imported module multiple times, isolateModules and isolateModulesAsync can help. They create a sandbox environment in your test and ensure individual imports don’t interfere with each other.

Testing Multiple Scenarios

The each functionality lets you run a given test or suite for several inputs at once — a great time-saver when testing multiple similar scenarios.

Extending Matching Functions

An interesting Jest extension is the jest-extended library. It offers several functions you’ll use daily writing tests and saves you keyboard strokes.

I hope you’ll use at least some of these tips when testing your applications, thereby reducing production bugs. Wishing you many caught errors and rightfully earned green checkmarks in your pipeline!

Ondřej Hajný
Ondřej Hajný
Frontend Developer, Cookielab

Ondrej is a frontend developer at Cookielab. He focuses on building clean, testable UI components and improving developer experience across projects.

Have a project that needs this level of care?

Talk directly to a founder. We'll listen first, advise honestly, and only build if it makes sense.

Cookielab founders — Radek Míka, Martin Homolka, Jakub Kohout
or

...your career.

Check our job openings