Engineering

Testing Async Code in Node.js: Patterns That Actually Work

Master async testing in Node.js! Discover proven patterns for Promises, Async/Await, and Callbacks that ensure reliable and robust code. Learn from real-world experiences.

· Founder & Engineer · · 8 min read
Abstract octopus wrestling a javascript code block, symbolizing the challenges of async testing.

Testing asynchronous JavaScript can feel like wrestling an octopus. At MisuJob, where we process 1M+ job listings to power our AI-powered job matching, reliable asynchronous code is paramount. We’ve battled our fair share of async bugs and learned some valuable lessons, so we’re sharing our proven patterns for robust testing in Node.js.

The Async Testing Landscape: Promises, Async/Await, and Callbacks

JavaScript’s asynchronous nature, while powerful, presents unique challenges for testing. Whether you’re using Promises, async/await, or even the occasional callback, ensuring your code behaves correctly requires a strategic approach. Let’s explore some patterns that have consistently worked for us.

Understanding the Pitfalls: Why Standard Testing Fails

A common mistake is treating asynchronous code as synchronous. This often leads to tests that pass prematurely, masking underlying issues. Consider this example:

// Incorrect test example (DO NOT USE)
const myFunction = async () => {
  await new Promise(resolve => setTimeout(resolve, 100));
  return 'Success!';
};

it('should return Success!', () => {
  myFunction().then(result => {
    expect(result).toBe('Success!');
  });
  // Test finishes before the promise resolves!
});

In this flawed example, the it block completes before the promise resolves, rendering the assertion meaningless. The test will likely pass (because it doesn’t actually wait for anything to happen), even if myFunction fails.

Pattern 1: Leveraging async/await in Tests

The async/await syntax simplifies asynchronous testing immensely. By declaring your test function as async, you can directly await the result of asynchronous operations, ensuring that your assertions are executed after the promise resolves.

const myFunction = async () => {
  await new Promise(resolve => setTimeout(resolve, 100));
  return 'Success!';
};

it('should return Success!', async () => {
  const result = await myFunction();
  expect(result).toBe('Success!');
});

This approach provides a cleaner, more readable, and more reliable way to test asynchronous code. The test function now waits for myFunction to complete before executing the assertion.

Pattern 2: Returning Promises for Callback-Based APIs

If you’re working with older callback-based APIs, you can still leverage the power of promises in your tests. Return the promise from your test function. This tells the testing framework (Jest, Mocha, etc.) to wait for the promise to resolve or reject before considering the test complete.

const myCallbackFunction = (callback) => {
  setTimeout(() => {
    callback(null, 'Success!'); // null for error, 'Success!' for result
  }, 100);
};

it('should return Success! via callback', () => {
  return new Promise((resolve, reject) => {
    myCallbackFunction((err, result) => {
      if (err) {
        reject(err);
      } else {
        expect(result).toBe('Success!');
        resolve();
      }
    });
  });
});

This pattern ensures that your test function waits for the callback to be executed before making any assertions. Failing to resolve() or reject() the promise will lead to a timeout, indicating a problem with your asynchronous code.

Pattern 3: Mocking and Stubbing Asynchronous Dependencies

Isolating units of code is crucial for effective testing. When dealing with asynchronous dependencies (e.g., external APIs), mocking and stubbing become essential. We often use Jest’s mocking capabilities for this purpose.

Consider a function that fetches data from a database:

// database.js
const fetchData = async (query) => {
  // Simulate database query
  await new Promise(resolve => setTimeout(resolve, 50));
  return { data: `Result for query: ${query}` };
};

module.exports = { fetchData };

// myService.js
const { fetchData } = require('./database');

const processData = async (query) => {
  const result = await fetchData(query);
  return `Processed: ${result.data}`;
};

module.exports = { processData };

To test processData without actually hitting the database, we can mock the fetchData function:

// myService.test.js
const { processData } = require('./myService');
const database = require('./database');

jest.mock('./database', () => ({
  fetchData: jest.fn().mockResolvedValue({ data: 'Mocked Data' }),
}));

it('should process data using mocked fetchData', async () => {
  const result = await processData('test query');
  expect(result).toBe('Processed: Mocked Data');
  expect(database.fetchData).toHaveBeenCalledWith('test query'); // Verify fetchData was called with correct arguments
});

By mocking fetchData, we isolate processData and ensure that our test focuses solely on its logic. We also verify that fetchData was called with the correct arguments.

Pattern 4: Testing for Errors and Rejections

Asynchronous operations can fail. Your tests should explicitly check for error conditions and ensure that your code handles them gracefully. Use try...catch blocks or .catch() promise handlers to catch potential errors.

const failingFunction = async () => {
  await new Promise((resolve, reject) => setTimeout(() => reject(new Error('Simulated Error')), 100));
};

it('should handle errors from failingFunction', async () => {
  await expect(failingFunction()).rejects.toThrow('Simulated Error');
});

This test uses expect(failingFunction()).rejects.toThrow('Simulated Error') to assert that failingFunction rejects with the expected error. This is crucial for ensuring that your application handles errors correctly and doesn’t crash unexpectedly.

Concurrency Considerations: Avoiding Race Conditions in Tests

When dealing with concurrent asynchronous operations (e.g., multiple promises executing in parallel), race conditions can be a significant source of bugs. Your tests should be designed to detect and prevent these issues.

Controlling Execution Order with Promise.all and Promise.race

Promise.all and Promise.race can be used to control the execution order of asynchronous operations in your tests. Promise.all waits for all promises to resolve before proceeding, while Promise.race resolves as soon as the first promise resolves or rejects.

it('should execute promises in parallel with Promise.all', async () => {
  const promise1 = Promise.resolve(1);
  const promise2 = Promise.resolve(2);
  const results = await Promise.all([promise1, promise2]);
  expect(results).toEqual([1, 2]);
});

These tools can be invaluable for testing scenarios involving concurrent operations and ensuring that your code behaves predictably under load.

Performance Testing of Asynchronous Operations

While correctness is paramount, performance is also a key consideration. Asynchronous operations can introduce performance bottlenecks if not handled efficiently. We use tools like autocannon and wrk to benchmark our asynchronous code. Here’s an example using autocannon:

autocannon -c 100 -d 10 http://localhost:3000/api/job-search

This command sends 100 concurrent connections to the http://localhost:3000/api/job-search endpoint for 10 seconds. Analyzing the results (requests per second, latency, errors) helps us identify performance bottlenecks in our asynchronous code.

Real-World Examples at MisuJob

At MisuJob, we use these patterns extensively when testing our AI-powered job matching algorithms. For example, when testing the function that ranks job listings based on user preferences, we mock the external API calls to different data sources and focus on testing the ranking logic itself. This allows us to iterate quickly and confidently on our ranking algorithms.

We also use these patterns when testing our system for processing and indexing new job listings. This system involves a complex series of asynchronous operations, including data validation, transformation, and indexing. By using mocking and stubbing, we can isolate and test each step of the process, ensuring that new job listings are processed correctly and efficiently.

Salary Data and Testing: Ensuring Accuracy

Accurate salary data is a critical component of our platform. We use rigorous testing to ensure that the salary information displayed to our users is accurate and up-to-date. Here’s a simplified example of how we might test a function that retrieves salary ranges for a given job title and location:

// salaryService.js (Simplified Example)
const getSalaryRange = async (jobTitle, location) => {
  // Simulate fetching salary data from a database or external API
  await new Promise(resolve => setTimeout(resolve, 25)); // Simulate network latency
  if (jobTitle === 'Software Engineer' && location === 'Berlin') {
    return { min: 60000, max: 80000 };
  } else if (jobTitle === 'Software Engineer' && location === 'London') {
    return { min: 55000, max: 75000 };
  } else {
    return { min: 40000, max: 60000 }; // Default range
  }
};

module.exports = { getSalaryRange };
// salaryService.test.js
const { getSalaryRange } = require('./salaryService');

it('should return the correct salary range for Software Engineer in Berlin', async () => {
  const salaryRange = await getSalaryRange('Software Engineer', 'Berlin');
  expect(salaryRange).toEqual({ min: 60000, max: 80000 });
});

it('should return the correct salary range for Software Engineer in London', async () => {
  const salaryRange = await getSalaryRange('Software Engineer', 'London');
  expect(salaryRange).toEqual({ min: 55000, max: 75000 });
});

it('should return a default salary range for unknown job titles and locations', async () => {
  const salaryRange = await getSalaryRange('Unknown Job', 'Unknown Location');
  expect(salaryRange).toEqual({ min: 40000, max: 60000 });
});

This example demonstrates how we use asynchronous testing to verify the accuracy of our salary data. We write tests for specific job titles and locations, as well as tests for default cases.

To provide a more concrete idea of salary ranges across different European countries, consider the following data (in EUR per year, approximate):

LocationJunior Software EngineerMid-Level Software EngineerSenior Software Engineer
Berlin, Germany45,000 - 55,00065,000 - 85,00090,000 - 120,000
London, UK40,000 - 50,00060,000 - 80,00085,000 - 110,000
Amsterdam, Netherlands48,000 - 58,00070,000 - 90,00095,000 - 125,000
Paris, France40,000 - 50,00060,000 - 75,00080,000 - 100,000
Stockholm, Sweden45,000 - 55,00065,000 - 85,00090,000 - 120,000

This data highlights the variations in salary ranges across different European cities, which is something we take into account when testing our salary data retrieval and display logic. We strive to provide our users with the most accurate and relevant salary information possible.

Conclusion

Testing asynchronous code requires a deliberate and strategic approach. By embracing patterns like async/await, mocking, and explicit error handling, you can build robust and reliable asynchronous applications. Remember to consider concurrency and performance when designing your tests. At MisuJob, these patterns have been instrumental in ensuring the quality and performance of our AI-powered job matching platform, which aggregates from multiple sources to provide the best possible experience for our users.

Key Takeaways:

  • async/await is your friend: Use it to simplify asynchronous tests and make them more readable.
  • Mock everything: Isolate your units of code by mocking asynchronous dependencies.
  • Test for errors: Explicitly check for error conditions and ensure that your code handles them gracefully.
  • Consider concurrency: Design your tests to detect and prevent race conditions.
  • Measure performance: Use tools like autocannon to benchmark your asynchronous code and identify bottlenecks.
node.js async testing javascript promises
Share
P
Pablo Inigo

Founder & Engineer

Building MisuJob - an AI-powered job matching platform processing 1M+ job listings daily.

Engineering updates

Technical deep dives delivered to your inbox.

Find your next role with AI

Upload your CV. Get matched to 50,000+ jobs. Apply to the best fits effortlessly.

Get Started Free

User

Dashboard Profile Subscription