Kalan's Blog

Software Engineer / Taiwanese / Life in Fukuoka

Current Theme light

Recently, this month's project development has completely changed my perspective on writing tests.

In terms of front-end development, I used to enjoy writing tests, not just unit tests, but also testing component functionality, such as simulating clicks and user interactions, due to developing with React. If Redux is used to manage side effects, I also write tests for Redux logic. Testing in the Redux world is relatively simple because it allows for pure functions.

For example, let's take a look at a simple scenario of fetching an article. If we split it into 3 actions: FETCH_ARTICLE, FETCH_SUCCESS, and FETCH_FAILED, triggered when the user clicks a button, the code might look 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);

We can break down the tests as follows:

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 when isLoading is false', () => {
    expect(...); // button doesn't exist and article is rendered
  });
});

Testing Redux 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 would say that these tests seem quite good and are already quite ideal. However, recently I have gradually realized that even if we write these tests thoroughly, there are always cases that we haven't considered, and they occur quite frequently, which made me start thinking about testing.

From the simple scenario mentioned above, we can consider some small questions:

  • What if the article return value is incorrect? Will accessing a certain field of the article cause any issues?
  • In the loading state, could unexpected errors such as unstable network, timeouts, parameter errors, server errors, or browser crashes occur? Is it appropriate to handle all of them as the same error?
  • Can the button be clicked repeatedly? Should we prevent users from clicking the button multiple times using disabled or other means?
  • Should the article be line-wrapped? If so, how should it be wrapped? Should we allow horizontal scrolling or vertical scrolling for users?
  • Are the wordings of the button and other parts correct?

These are the parts that are not covered in the above tests. You might ask, why don't we gradually add them? But while adding tests, we are also modifying the components. Although it may be more reassuring to modify them, unexpected scenarios always arise after the modifications, leading to a situation where QA issues are endless, deployment is delayed, and everyone is in a mess.

There are too many scenarios in front-end development that require interaction with the UI, and it is difficult to achieve such behavior through general testing.

Recently, in the projects I've been working on, my perspective on writing tests has changed the most. Don't get me wrong, I still try my best to write tests, but I want to focus on more meaningful tests rather than writing seemingly irrelevant test cases just for fun.

So, although writing tests is necessary for me now, it doesn't mean that I have to start writing tests right from the beginning.

The most important step is to thoroughly clarify all the requirements. That's really the key, there's no other way.

Many QA issues have occurred because we didn't thoroughly understand the requirements at the beginning and there was a gap in the understanding of the requirements between both parties.

For example, in the previous scenario of fetching an article, we can consider when to call the API, what possible responses the API can have, whether additional handling is required for various API situations, whether special handling is needed for the UI content (like line breaks or other UI considerations), whether repeated clicks can occur, and so on. Once all of these are confirmed, we can start writing tests without rushing.

The reason is simple: the UI that users see is the medium through which they interact with the application, but we rarely do this kind of end-to-end testing (although this concept is gradually being corrected, which is fortunate), which causes us to struggle with UI issues no matter how many tests we write. Because creating a complete end-to-end test is challenging and sometimes requires collaboration with the backend (such as preparing a mock database and environment), making the test environment as realistic as possible.

Another point is that if we want to write tests, we shouldn't spend too much time on things like unit tests. Of course, we should still test what needs to be tested, but there's no need to obsess over something as obvious as expect(1+1).toBe(2).

In this regard, I highly recommend using Cypress and Puppeteer for integration testing (Puppeteer can also be used for other purposes). They provide comprehensive functionality and have everything you need. With these tools, inconvenience is no longer an excuse.

Pay Attention to Code That Calls APIs in useEffect

Although useEffect is designed for executing side effects, it can easily become a nightmare if you're not careful. If the API response modifies the internal state of the component, remember to cancel the API request when the component unmounts to avoid potential memory leaks.

Why is that? If you unmount the component before the API request is completed and the API returns afterwards, during the execution of the callback, a warning will be thrown because the callback is modifying the state of an already unmounted component.

Also, code that directly fetches APIs in useEffect (like fetch()) is really difficult to test... Writing tests for a component like this requires extensive mocking and careful comparison to align with the API response. It's better not to opt for convenience at the expense of introducing issues.

State Explosion

Another source of many QA bugs comes from the complexity of state management. In the early stages of iteration, when there aren't many states, using simple useState might be sufficient. However, as the number of states increases, if statements are scattered everywhere, and even adding a new state can make you doubt your life choices. Especially when you see code like this:

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

If it only appears in one place, it might be fine, but if this kind of code is all over the component, it's almost guaranteed to result in multiple QA issues. Writing too many tests cannot mask the complexity of state management or turn bad code into good code. Even if your tests cover all the states at the moment, errors can still occur when new states are added.

That's why finding a way to manage state elegantly is a big question in front-end development. Although writing Redux is always annoying, this approach helps write more maintainable code and, more importantly, centralizes the management of complex states through reducers.

In React, we can use useReducer to avoid state explosion because reducers essentially act as state machines, allowing us to examine all the state changes through the reducer function. However, this is still not enough because states are not like enums; they are more like trees or graphs, and state changes have dependencies. For example, the idle state doesn't directly transition to the success state; it must first transition to the loading state and then to the success state. Similarly, the failed state doesn't transition to the idle state; it can only go back to the loading state and then transition to a specific state based on the response.

Regular reducers cannot achieve this. They cannot detect state dependencies, which means that apart from relying on the discipline and integrity of engineers, there is no other way. But we all know that an engineer's integrity is the least trustworthy thing in the world, so mistakes can happen.

A better approach is to model states using a language that can describe states more accurately. Recently, xstate has gained popularity and is worth exploring. It covers almost all scenarios in state management. If you're interested, you can study it.

Prev

Software Engineering Devastation

Next

Twitter Dependencies

If you found this article helpful, please consider buy me a drink ☕️ It'll make my ordinary day shine✨

Buy me a coffee

作者

Kalan 頭像照片,在淡水拍攝,淺藍背景

愷開 | Kalan

Hi, I'm Kai. I'm Taiwanese and moved to Japan in 2019 for work. Currently settled in Fukuoka. In addition to being familiar with frontend development, I also have experience in IoT, app development, backend, and electronics. Recently, I started playing electric guitar! Feel free to contact me via email for consultations or collaborations or music! I hope to connect with more people through this blog.