Tackling Flaky API Tests in React Projects Using Axios or Fetch

Semaphore
16 min readApr 10, 2024

--

In React development, Axios and Fetch streamline API communication but maintaining stable API tests can be tricky. Flaky tests, prone to occasional failures, pose a common challenge. This article explores strategies to combat flakiness, ensuring reliable testing in React projects.

Importance of API Testing in React Projects

React’s ability to connect with external APIs makes it ideal for dynamic apps. Reliable API communication is essential for a smooth user experience, and that’s where API testing ensures flawless data interactions in React projects.

API testing is crucial in React projects for several reasons:

  • Integration Verification: API testing checks if the frontend and backend of a website or app work together properly. It makes sure that the parts of the app that users see (like buttons and text) talk correctly to the parts users don’t see (like databases or other services). For example, it makes sure that when you click a button in a React app, it talks to the right place and gets the right information.
  • Functionality Testing: It validates whether API endpoints return correct data formats, status codes, and responses, ensuring that React components consume API responses correctly.
  • Security Testing: API testing includes security testing to identify vulnerabilities, protecting both backend systems and React frontends from potential security threats.
  • Error Handling: API testing allows you to test error scenarios, such as when the API returns error responses or encounters validation issues. By simulating such scenarios, you can ensure that the React application handles errors gracefully and provides appropriate feedback to users.

Highlighting the Issue of Flaky Tests and Their Negative Impact

Flaky tests are a common problem in software development, often overlooked but with significant consequences. Below are some of the negative impacts of flaky tests:

  • Reduced Confidence: When tests are flaky, they act unpredictably; sometimes passing, sometimes failing without any changes to the code. This inconsistency makes developers doubt whether the tests accurately reflect the quality of their work.
  • Decreased Productivity: Dealing with flaky tests consumes valuable time and resources, diverting developers’ focus from productive tasks to troubleshooting and debugging.
  • Delayed Bug Detection: Flaky tests can make it hard to spot real problems because they randomly pass or fail. This confusion delays fixing actual bugs, slowing down the process of improving the software.
  • False Sense of Security: Flaky tests sometimes pass unexpectedly, making developers think everything’s fine with their code. It’s like getting lucky on a test you didn’t study for — it might give you a false sense of confidence. This can lead developers to overlook real issues in the code.

Popular HTTP Client Libraries in React: Axios and Fetch

For making HTTP requests in React applications, developers have two popular choices: Axios and Fetch. These libraries streamline communication with APIs and backend services, enabling developers to prioritize building exceptional user interfaces.

Axios is a popular library in React known for its concise and user-friendly approach to making HTTP requests. It allows developers to perform various actions like fetching data, sending updates, and more, using clear methods like GET, POST, and DELETE. Axios simplifies asynchronous operations through its promise-based API, making it ideal for interacting with RESTful APIs. It provides features such as interceptors for request and response handling, making it highly customizable and versatile.

On the other hand, Fetch is an inbuilt browser API for making HTTP requests, widely supported across modern browsers. It offers a straightforward interface for fetching resources and supports promises, making it compatible with modern JavaScript patterns. Fetch is lightweight and built into the browser, eliminating the need for additional dependencies in React projects. However, Fetch’s simplicity also means it lacks some advanced features found in Axios, such as request cancellation and global error handling.

Both Axios and Fetch have their strengths and weaknesses, and the choice between them often depends on the specific requirements of the project. While Axios provides a more feature-rich and user-friendly experience, Fetch offers a lightweight and native solution for basic HTTP requests. Ultimately, developers should evaluate their project’s needs and preferences when selecting the most suitable HTTP client library for their React applications.

Understanding Flaky Tests

In this section, we’re going to take a closer look at what flaky tests are all about. Whether you’re new to software testing or an experienced developer, understanding flaky tests is crucial. So, let’s dive in.

Definition of Flaky Tests

Flaky tests are those unstable things in the world of software testing that occasionally pass or fail without any changes to the codebase. They are like the chameleons of the testing world, changing their results seemingly at random. Flakiness slows down the development process and decreases trust in test outcomes. Below are some characteristics of flaky tests:

  1. Inconsistency: Flaky tests exhibit inconsistent behavior, passing or failing under different conditions or environments.
  2. Unpredictable: Unlike reliable tests that produce consistent outcomes for the same input, flaky tests behave unpredictably, making it challenging to pinpoint the root cause of failures.
  3. External Dependencies: Tests relying on external resources like databases, APIs, or third-party services are prone to flakiness due to factors beyond the developer’s control, such as network latency or service availability.

Common causes of flakiness in API tests (e.g., network issues, race conditions, external dependencies)

Flakiness in API tests can be a source of frustration for developers, resulting in unreliable test outcomes and wasted time spent diagnosing false positives or negatives. Below, are some of the factors contributing to API flakiness:

Network Issues: Network instability can have a major impact on the reliability of API tests. Frequent test failures can be caused by fluctuations in data loss, delays in the network, or server failures, which might cause unexpected responses or timeouts. Testing environments with different network conditions can produce varying results, making it difficult to pinpoint the reasons for failures. To tackle network issues, testers can use techniques such as simulating API responses, using placeholders or virtual services, or conducting tests in controlled network setups. By separating tests from external factors, teams can achieve more consistent and trustworthy results. Here’s an example code demonstrating how to simulate API responses using Axios:

// Simulating API responses (using Axios)
axios
.get("/api/data")
.then((response) => {
// Simulate a network delay
return new Promise((resolve) =>
setTimeout(() => resolve(response.data), 2000),
);
})
.then((data) => {
// Your test assertions here
})
.catch((error) => {
// Handle errors appropriately
});
// Using a mocking library (e.g., nock) with Fetch
nock("https://api.example.com")
.get("/data")
.reply(200, { message: "Mocked response" });
fetch("https://api.example.com/data")
.then((response) => response.json())
.then((data) => {
// Your test assertions here
})
.catch((error) => {
// Handle errors appropriately
});

The first code snippet simulates an API response with a 2-second network delay using Axios, while the second snippet uses a mocking library (like Nock) to intercept actual API calls and provide a predefined response, ensuring tests remain consistent and independent of external APIs.

Race Conditions: In API testing, race conditions occur when test results depend on the timing or order of events. This happens when several requests occur simultaneously or during asynchronous operations. Such situations can lead to unpredictable behavior because the test outcome may vary based on which request completes first, particularly if both are trying to modify the same resource at once. Testers can address race conditions by scheduling operations, employing wait mechanisms, or inserting delays between requests. Additionally, designing tests with independent operations and clear boundaries can minimize the impact of race conditions on test results. Here’s an example code showcasing how to schedule operations with Axios:

// Scheduling operations with Axios
async function fetchData() {
const response1 = await axios.get("/api/data1");
const response2 = await axios.get("/api/data2");
// Use the data from both responses
  // Alternatively, use Promise.all for parallel execution with control
const [response1, response2] = await Promise.all([
axios.get("/api/data1"),
axios.get("/api/data2"),
]);
}
// Using async/await with Fetch
async function fetchData() {
const response1 = await fetch("/api/data1");
const response2 = await fetch("/api/data2");
const data1 = await response1.json();
const data2 = await response2.json();
// Use the data from both responses
}

These code examples show fetching data from two APIs sequentially (one after another) using either Axios or Fetch with async/await. They don’t involve timed scheduling, but rather managing asynchronous calls effectively.

External Dependencies: API tests often require external services, databases, or third-party APIs to imitate real-world interactions. Changes or disruptions in these dependencies can disrupt tests. For example, updates to API endpoints or service outages can lead to unexpected failures. Testers should use less of these external factors by imitating or replacing them whenever possible. This prevents test failures caused by changes or disruptions in external dependencies. Here’s an example code demonstrating how to mock external dependencies with Axios:

// Mocking external dependencies with Axios
const mockData = { message: "Mocked response" };
axios
.get("/api/data")
.then(() => Promise.resolve(mockData)) // Mock the response
.then((data) => {
// Your test assertions here
})
.catch((error) => {
// Handle errors appropriately
});
// Mocking external dependencies with Fetch (using nock)
nock("https://api.example.com").get("/data").reply(200, mockData);
fetch("https://api.example.com/data")
.then((response) => response.json())
.then((data) => {
// Your test assertions here
})
.catch((error) => {
// Handle errors appropriately
});

These code snippets showcase mocking external dependencies (like APIs) during tests. The first example uses Axios, intercepting the request and resolving with predefined mockData. The second example uses Nock with Fetch, mimicking the API response at a specific URL (https://api.example.com/data) with the provided mockData. This allows testing to proceed without relying on actual external services.

By incorporating these code examples and techniques, you can significantly enhance the reliability and maintainability of your API tests in React projects.

Strategies for Mitigating Flaky Tests

Below are some of the strategies used in reducing flaky tests:

Network Resilience

Network resilience refers to the ability of a system to maintain functionality and performance in the face of network disruptions or failures. It involves implementing strategies to mitigate the impact of network-related issues on the system’s operation and reliability.

Implementing Retry Mechanisms with Exponential Backoff: Retry mechanisms with exponential backoff are a strategy used to handle transient failures in network communication. When a request fails due to network issues, the system automatically retries the request after a brief delay, with each subsequent retry increasing the delay exponentially. This approach allows the system to wait for temporary issues to be resolved before retrying, reducing the likelihood of repeated failures and improving overall reliability. Here’s an example of implementing retry logic with exponential backoff using async/await:

async function fetchDataWithRetry(url) {
let retries = 0;
const wait = (delay) => new Promise((resolve) => setTimeout(resolve, delay));
  while (retries < 3) {
// Adjust retry limit as needed
try {
const response = await axios.get(url);
return response.data;
} catch (error) {
console.warn(`Error fetching data, retrying (${retries + 1}/${3})...`);
retries++;
await wait(Math.pow(2, retries) * 1000); // Exponential backoff
}
}
throw new Error("Failed to fetch data after retries");
}

The code above defines a function fetchDataWithRetry that attempts to fetch data from a URL. If the request fails due to network issues, it retries the request with an increasing delay between attempts (exponential backoff) to avoid overwhelming the server with repeated requests.

Stabilizing Tests through Mocking External API Responses: Mocking external API responses involves simulating the behavior of external dependencies, such as APIs, during testing. Instead of making real requests to external services, the tests use mocked responses that are predefined by the developer. This ensures that tests remain stable and consistent, even if the external APIs are unavailable or unreliable. By decoupling tests from external dependencies, developers can create a controlled testing environment that is immune to fluctuations in external services, reducing the occurrence of flaky tests. Here’s an example using Jest for mocking external API responses:

// Assuming a library like Jest for mocking
jest.mock("your-api-module");
test("MyComponent fetches data successfully", async () => {
const mockedData = { message: "Success!" };
yourApiModule.fetchData.mockResolvedValue(mockedData);
const component = render(<MyComponent />);
await waitFor(() => expect(component.text()).toContain(mockedData.message));
});

The code above demonstrates how to mock the behavior of an external API module (your-api-module) using Jest’s mocking functionalities. The test defines mocked data and configures the fetchData function of the mocked module to return this data. This allows the test to run in isolation from the actual API, ensuring stability even if the real API is unavailable or unreliable.

Code Improvements

This section focuses on enhancing the quality and reliability of tests by making improvements directly to the codebase.

Refactoring Tests to Isolate Dependencies: This means restructuring tests to separate dependencies like external services or databases from the main logic being tested. By relying less on external factors, tests become more stable and less likely to have problems because of changes in external systems. Here’s an example of isolating external service interaction in a separate function:

// Extract logic interacting with external service to a separate function
function getUserData(userId) {
return fetch(`https://api.example.com/users/${userId}`);
}
test("MyComponent displays user data", async () => {
const mockUserData = { name: "John Doe" };
jest
.spyOn(window, "fetch")
.mockResolvedValueOnce(new Response(JSON.stringify(mockUserData)));
const component = render(<MyComponent userId={1} />);
await waitFor(() => expect(component.text()).toContain(mockUserData.name));
});

This code refactors the test logic to separate the functionality of fetching user data from the core component logic. The getUserData function encapsulates the interaction with the external service, making the test more focused on the component’s behavior and less reliant on external factors.

Harnessing the Power of Test Doubles (Stubs and Spies) for Enhanced Control: Test doubles, such as stubs and spies, give developers strong tools to manage dependency behavior during testing. Stubs give pre-decided responses to method calls, while spies help monitor and check interactions with dependencies. Using test doubles lets developers control test scenarios better, leading to more reliable and predictable test results. Here’s an example using Sinon for stubs/spies:

// Using Sinon for stubs/spies (replace with your chosen library)
const sinon = require("sinon");
test("MyComponent calls external service with correct parameters", () => {
const spy = sinon.spy(window, "fetch");
render(<MyComponent />); expect(spy.calledOnce).toBe(true);
expect(spy.firstCall.args[0]).toEqual("https://api.example.com/data");
});

This code demonstrates how to use Sinon’s spy function to monitor interactions with the fetch function during the test. The test verifies that the component calls the fetch function with the correct URL, ensuring the component interacts with the external service as expected.

Test Design

In test design, the focus is on creating effective and efficient test cases that accurately assess the behavior of the software under test. This involves two key sub-strategies:

Crafting Clear and Concise Test Cases Focused on Specific Behaviors: This approach highlights the need for clear and focused test cases. Testers should ensure that each case has a clear purpose, with defined expected outcomes and input conditions. This clarity helps accurately reflect the intended behavior of the software. Here’s an example of a test focused on error handling:

test("MyComponent renders an error message when data fetching fails", async () => {
jest.spyOn(window, "fetch").mockRejectedValueOnce(new Error("Network Error"));
  const component = render(<MyComponent />);  await waitFor(() =>
expect(component.text()).toContain("Error fetching data"),
);
});

This code defines a test that simulates a network error during data fetching and verifies that the component displays an appropriate error message to the user.

Organizing Tests Logically and Grouping Them by Functionality: This approach focuses on organizing test suites logically by grouping related tests based on the functionality they cover. It simplifies test management and execution, improving readability and maintainability. Testers can easily find relevant tests and understand dependencies between different software components. This approach is typically implemented through a test suite file structure. For instance:

// user.test.js
test('MyComponent displays user name', async () => { ... });
test('MyComponent renders error for invalid user ID', async () => { ... });
// data.test.js
test('MyComponent fetches data successfully', async () => { ... });
test('MyComponent handles data fetching errors', async () => { ... });

These test files (.js) organize tests for a component (MyComponent) by functionality. user.test.js likely holds tests for user interactions (name display, invalid ID handling), while data.test.js focuses on data fetching scenarios (success, error handling). This separation improves code clarity.

Axios vs. Fetch for Flaky Test Mitigation

In web development, minimizing flaky tests is crucial for maintaining code reliability. Choosing the right HTTP client, such as Axios or Fetch, can greatly impact this. Let’s explore how each helps mitigate flakiness in tests.

Comparing Axios and Fetch: Error Handling, Cancellation, and Request/Response Manipulation

Error Handling: Axios and Fetch handle errors differently. Axios provides built-in error-handling capabilities, allowing developers to easily catch and handle errors using interceptors. This simplifies error management and enhances the overall reliability of the application. Here’s an example of error handling with Axios:

import axios from "axios";
axios.interceptors.response.use(
(response) => response,
(error) => {
// Handle error here
if (error.response) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
console.error(error.response.data);
console.error(error.response.status);
console.error(error.response.headers);
} else if (error.request) {
// The request was made but no response was received
// `error.request` is an instance of XMLHttpRequest in browser environments
console.error(error.request);
} else {
// Something happened in setting up the request that triggered an Error
console.error("Error", error.message);
}
return Promise.reject(error);
},
);
// Example usage
axios
.get("https://api.example.com/data")
.then((response) => {
// Handle successful response
})
.catch((error) => {
// Handle error based on error object properties
});

On the other hand, Fetch requires developers to manually check the response status and throw errors accordingly, making handling errors more difficult to understand and possibly more vulnerable to mistakes. Here’s an example of error handling with Fetch:

fetch("https://api.example.com/data")
.then((response) => {
if (!response.ok) {
// Handle error using response.text() or response.json()
response.text().then((text) => {
console.error(text);
});
throw new Error(`Request failed with status ${response.status}`);
}
return response.json();
})
.then((data) => {
// Handle successful response
})
.catch((error) => {
// Handle error
console.error(error);
});

Cancellation: Canceling requests is important in web development, particularly when users move away from a page or do something that makes ongoing requests unnecessary. Axios makes canceling requests easy with a built-in cancellation token feature. This allows developers to cancel requests without hassle.

import axios from "axios";
const source = axios.CancelToken.source();axios
.get("https://api.example.com/data", { cancelToken: source.token })
.then((response) => {
// Handle successful response
})
.catch((error) => {
if (axios.isCancel(error)) {
console.log("Request canceled");
} else {
// Handle other errors
}
});
// Cancel the request
source.cancel("Request is no longer needed");

On the other hand, Fetch doesn’t have built-in support for cancellation. Developers have to create their own solutions using the AbortController interface, which can be more complicated and prone to errors. Here’s an example of cancellation with Fetch (using AbortController):

const controller = new AbortController();
const signal = controller.signal;
fetch("https://api.example.com/data", { signal })
.then((response) => {
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return response.json();
})
.then((data) => {
// Handle successful response
})
.catch((error) => {
if (error.name === "AbortError") {
console.log("Request canceled");
} else {
// Handle other errors
}
});
// Cancel the request
controller.abort();

Request/Response Manipulation: it’s common to need to change how requests and responses work. Axios makes this easy with its simple way of intercepting and adjusting them using interceptors. This allows for customizing things like how requests are sent or how responses are handled. Here’s an example of Request/Response Manipulation with Axios:

import axios from "axios";
axios.interceptors.request.use((config) => {
// Modify request config (e.g., add headers, authorization)
config.headers.Authorization = `Bearer ${accessToken}`;
return config;
});
axios.interceptors.response.use((response) => {
// Modify response data (e.g., transform or filter data)
return response.data.results; // Assuming response has a `results` property
});
// Example usage
axios
.get("https://api.example.com/data")
.then((data) => {
// Data will be the transformed results
})
.catch((error) => {
// Handle error
});

Fetch can also achieve similar functionality, but it usually involves writing more code using middleware functions. Here’s an example of Request/Response Manipulation with Fetch (using middleware functions):

async function fetchData(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const data = await response.json();
  // Modify response data before returning
return processData(data);
}
async function processData(data) {
// Transform or filter data
return data.map((item) => ({ id: item.id, name: item.name.toUpperCase() })); // Example transformation
}
// Example usage
fetchData("https://api.example.com/data")
.then((data) => {
// Data will be the transformed results (uppercase names)
})
.catch((error) => {
// Handle error
});

As you can see, Axios provides a more concise and convenient way to manipulate requests and responses using interceptors. Fetch requires creating separate functions for manipulation, making the code potentially more verbose.

Exploring Flakiness Mitigation: Features and Approaches in Axios and Fetch

Deciding between Axios and Fetch can have a big impact on the success of the search to reduce flakiness in online apps. Let’s examine the features and methods used by each of these HTTP clients to mitigate flakiness using React samples.

Retries and Exponential Backoff: Retries are when a system tries again after an error, and exponential backoff is a strategy where the time between retries is increased to help ease congestion and improve success rates.Below we discuss how Axios and Fetch handles Retries and Exponential Backoff.

  • Axios: Axios, a tool for making HTTP requests, offers an easy way to handle retries using a technique called exponential backoff. With Axios’s interceptors feature, developers can set up automatic retries for failed requests. These retries happen at increasing intervals, which helps applications recover from temporary network problems more effectively, making them more resilient.
  • Fetch: With Fetch, developers can manually implement retry logic with exponential backoff using custom functions. While not as streamlined as Axios, Fetch provides flexibility in handling retries according to specific project requirements.

Error Handling: Error handling is how programs manage unexpected problems. It’s about detecting errors, deciding what to do, and finding ways to fix or work around them. This might involve using special tools like exceptions or codes to deal with errors when they happen. Below we discuss on how Axios and Fetch handles errors.

  • Axios: Axios simplifies error handling with its built-in interceptor for handling response errors. Developers can easily catch and handle errors in a centralized manner, improving code readability and maintainability.
  • Fetch: Error handling with Fetch requires developers to manually check the response status and throw errors accordingly. While this provides flexibility, it can lead to boilerplate code and potential inconsistencies in error handling.

Axios and Fetch both address flakiness in web apps. Axios offers built-in solutions like retries and error handling, while Fetch allows for customization. Developers should weigh their project needs and preferences when deciding between these libraries for flakiness mitigation in React apps.

Leveraging Axios and Fetch for Stable Requests: Strategies to Reduce Flaky Tests

Axios and Fetch stand out as widely-used JavaScript libraries designed for managing HTTP requests, known for their effectiveness in reducing test flakiness. When it comes to stabilizing requests, developers often rely on retry mechanisms employing exponential backoff. Axios streamlines this process by offering built-in support for interceptors, facilitating the implementation of custom retry strategies such as exponential backoff. Similarly, Fetch provides the flexibility to incorporate custom retry logic through JavaScript’s Promise chaining mechanism.

Another strategy involves mimicking responses from external API. Both Axios and Fetch support this feature in tests. By doing so, developers can simulate the behavior of external services without actually relying on them. This decoupling of tests from external dependencies creates a controlled testing environment, it helps maintain consistent and predictable results, unaffected by external factors.

Arranging test groups in a clear way and putting similar tests together makes testing easier and more reliable. It helps keep things organized, makes tests easier to read, and shows how different parts of the program rely on each other. To make sure tests run smoothly and accurately, using tools like Axios and Fetch, along with techniques like retrying failed tests and creating fake versions of parts of the program, can be really helpful. This means that the software runs smoothly and reliably. By carefully planning and putting these methods into action, developers can be sure they’re making top-notch software they can trust.

Conclusion

It’s really important to handle unreliable API tests properly in React projects so that the app stays stable and dependable. Ways to do this include retrying tests when they fail, pretending the API responses during testing, and using helpful tools like Axios or Fetch. By doing these things, developers can reduce the chance of tests giving inconsistent results and make sure the software they build is strong and reliable. By being proactive and thinking carefully about how they test, React projects can keep working smoothly and give users a great experience.

Originally published at https://semaphoreci.com on April 10, 2024.

--

--

Semaphore

Supporting developers with insights and tutorials on delivering good software. · https://semaphoreci.com