· 8 min read

Some Thoughts on Writing Tests (Frontend)

This article was auto-translated from Chinese. Some nuances may be lost in translation.

My project development over the past month has completely changed my perspective on writing tests.

On the frontend, I used to love writing tests. Not just unit tests—since I develop with React, I would also test component functionality, such as simulating clicks and user interactions. When using Redux to manage side effects, I would also write tests related to Redux logic. Because things can remain pure in the Redux world, writing tests was relatively straightforward.

For example, take an article-fetching feature. If we break it down into three actions—FETCH_ARTICLE, FETCH_SUCCESS, and FETCH_FAILED—triggered when a user clicks a button, the code might look something like this:

const Article = ({
  fetchArticle,
  status,
  article,
}) => {
  if (status === 'idle') {
		return <button onClick={fetchArticle}>click me</button>
  } else if (status === 'loading') {
    return <Loading />
  }
  
  if (status === 'error') {
    return <Error />
  }
  return isLoading ? null : <article>{article}</article>
};

const mapStateToProps = state => ({
	article: state.article,  
  status: state.article.status,
});

const mapDispatchToProps = {
  fetchArticle,
}

export default connect(mapStateToProps, mapDispatchToProps)(Article);

As for tests, we can break them down into a few parts:

describe('<Article/>', () => {
  it('should render button when isLoading', () => {
    expect(...); // button exists 
  });
  
  it('should call fetchArticle if button is clicked', () => {
    find(button).simulate('click');
    expect(...) // fetchArticle should be triggered
  })
  
  it('should render article isLoading == false', () => {
    expect(...); // button doesn't exists and article get rendered
  });
});

The Redux part is even simpler:

describe('actions', () => {
  it('should return correct type', () => {
    expect(fetchArticle()).toBe({
	    type: 'FETCH_ARTICLE',
  	});
  });
	
  it('should return correct type and response', () => {
    expect(fetchSuccess(content)).toBe({
	    type: 'FETCH_ARTICLE_SUCCESS',
      payload: content,
  	});
  });
  
  it('should return correct type', () => {
    expect(fetchSuccess(err)).toBe({
	    type: 'FETCH_ARTICLE_FAILED',
      payload: err,
  	});
  });
});

describe('reducers', () => {
  it('should return correct state', () => {
    expect(reducer(fetchArticle(), initialState), {
      status: 'loading',
      article: null
    });
  });
  
  it('should return correct state', () => {
    expect(reducer(fetchArticleSuccess(content), initialState), {
      status: 'loaded',
      article: content
    });
  });
  
  it('should return correct state', () => {
    expect(reducer(fetchArticleFailed(err), initialState), {
      status: 'error',
			error: err,
    })
  });
});

I’d say this looks like a decent set of tests; in fact, it’s pretty ideal already. However, recently I’ve come to realize that no matter how hard you try to make these tests comprehensive, unconsidered cases always pop up—and quite frequently at that. This got me thinking about the whole idea of testing.

Looking at the simple scenario above, we can consider a few subtle questions:

  • What if the article API returns invalid data? What happens if you try to access a specific field on the article at that point?
  • During the loading state, unexpected errors like an unstable network, timeouts, invalid parameters, server errors, browser unmounting, etc., could occur. Is it really appropriate to lump all of them into a single error state?
  • Could there be double-clicking issues with the button? Should we use disabled or other means to prevent users from repeatedly clicking the button?
  • Does the article text need line breaks? If so, how should it break? Should the user scroll horizontally or vertically?
  • Are the button labels and various UI copy accurate?

None of these were covered in the tests above. You might ask, “Can’t we just add them gradually?” But while adding tests, we’re also modifying the component. Sure, having tests gives you some peace of mind when making changes—that’s what I used to think too. But after making changes, unexpected scenarios always crop up again. You end up trapped in an endless cycle of fixing QA bugs, delayed deployments, and everyone getting completely burned out.

In frontend development, there are too many UI interaction scenarios that standard tests simply can’t easily capture.

Later, I realized something: simply writing test cases cannot magically conjure up scenarios (or states) you hadn’t thought of in the first place. Moreover, on the frontend, if you don’t actually simulate real user behavior, you run straight into issues across different browsers, devices, and real-world conditions (like unstable networks).

In my recent projects, the biggest shift has been my mindset toward writing tests. Don’t get me wrong—I still write tests whenever possible, but I want to focus on more meaningful tests rather than churning out trivial test cases just to comfort myself.

So while writing tests is a must for me now, diving headfirst into writing tests shouldn’t be the very first step.

The most critical step is to relentlessly clarify all the requirements. It really is that simple; there’s no way around it.

Too many times, QA issues arise simply because we didn’t thoroughly understand the requirements in the first place, or because there was a gap between different parties’ understanding of the specs.

For instance, taking the article-fetching example from earlier: at what point should the API be called? What are the possible API responses? Do specific API conditions require special handling? Does the UI content require special treatment (like the line breaking mentioned earlier or other UI considerations)? What about duplicate clicks? It’s never too late to start writing tests once all of these are confirmed.

The reason is simple: the UI seen by users is the medium through which they interact with the application. Yet in practice, we rarely run end-to-end (E2E) tests like this (fortunately, this mindset is gradually changing). As a result, no matter how many tests we write, we still stumble on the UI. Building a comprehensive E2E test is difficult and sometimes even requires backend collaboration (such as preparing mock databases and environments) to make the test environment as realistic as possible.

Another point is that if you’re writing tests, don’t spend too much time on unit tests. Of course, you should still test what needs testing, but there’s no need to obsess over blatantly obvious things like expect(1+1).toBe(2).

In this regard, I highly recommend Cypress and Puppeteer for integration testing (though Puppeteer can be used for other things as well). They are feature-rich and have everything you need. With tools like these, inconvenience is no longer an excuse to avoid them.

Beware of Code Calling APIs Inside useEffect

While useEffect is designed for running side effects, it can easily turn into a nightmare if you’re not careful. If your API response updates the component’s internal state, remember to cancel the API request on unmount; otherwise, it could lead to potential memory leaks.

Why? If you unmount a component before the API request completes, when the API returns and executes its callback, it will throw a warning because the callback is attempting to update the state of a component that has already unmounted.

Also, APIs called directly inside useEffect (like calling fetch() straight away) are genuinely hard to test… Writing tests for such a component requires an agonizing amount of mocking just to get started, and you have to meticulously verify that everything aligns with the API response. Don’t compromise for short-term convenience only to sow seeds of trouble later.

State Explosion

Furthermore, many QA bugs originate from the complexity of state management. In the early stages of iteration, when there aren’t many states, a simple useState might be all you need to get by. But as states multiply and if statements scatter everywhere, introducing even a single new state can make you question your life choices—especially when you see code like this:

if (isLoading && !isEditing && profile && isNotEmpty && isLoggedIn) {
  // fuck my logic
}

If it’s just in one place, it might be manageable. But if a component is littered with code like this, nine times out of ten, it’s going to trigger several QA bugs. No amount of tests can mask the complexity of messy state management, nor can tests turn bad code into good code. Even if your current tests cover all states, errors are still bound to happen when adding new states.

That’s why managing state—elegantly, that is—remains one of the biggest challenges in frontend development. While writing Redux can often be tedious, the approach helps developers write more maintainable code, and more importantly, it centralizes complex state management through reducers.

In React, we can use useReducer to prevent state explosion because a reducer is essentially like a state machine, allowing us to inspect all current state transitions through the reducer function. Even so, that’s not quite enough. States aren’t just enums; they are more like a tree or a graph with dependencies between transitions. For example, an idle state shouldn’t jump directly to success—it must transition to loading first and then to success. Or, a failed state shouldn’t jump back to idle; it should only transition back to loading, and then decide which state to move to based on the response.

A regular reducer can’t enforce this. It has no way of validating state transition dependencies, which means you have to rely entirely on the discipline and integrity of engineers. But as we all know, an engineer’s self-discipline is the least reliable thing in the world, so mistakes are inevitable.

A better approach is to model states using a language better suited for describing them. The increasingly popular xstate is definitely worth a look—it covers virtually every scenario in state management. If you’re interested, it’s well worth exploring.

Related Posts

Explore Other Topics