One of the most common actions in modern web applications is making asynchronous calls to external services. Having your application make a request to another server and update the page without reloading is a great user experience.
However, it can make things difficult for testing. You often won't have control of these external services, meaning you can't guarantee its responses.
How can you ensure you're testing the right things consistently for these services when you're running end-to-end tests? Thankfully, most test frameworks can control what these services return using mocks.
Using mocks to make testing easier
In the software testing world, a mock is an object that simulates the real behavior of an existing implementation. The implementation is anything from a single function to an entire service or system.
During testing, you can use mocks to easily test parts of your application that are inconvenient or tricky to test. Here are a few common scenarios where mocks are useful:
- Sometimes your applications perform intensive calculations that take time to perform. You can bypass these more-demanding functions with a mock to keep your tests speedy.
- When you need to test time-sensitive code like checking for a future event, mocks allow you to control the system time. You won't have to wait for the event to trigger in real-time during testing.
- If you practice test-driven development (TDD), mocking accelerates the process by allowing you to test complex interfaces with ease. It also allows you to test interfaces that don't exist yet.
- There are times you want to test a scenario that doesn't typically occur, such as a network error. You can mock an error response so you can cover those scenarios on every test run when needed.
- Some applications need or have sensitive information that you don't want to expose during testing. You can bypass these calls with a mock and keep your information safe inside of your tests.
As you can see, mocks are useful in a variety of test scenarios. They're a powerful tool to wield when you can't automate an action, or something is problematic to test.
Mocking responses from an external API with TestCafe
Another typical use case for mocking is when an application communicates with an external API. Interacting with third-party APIs in tests have a few potential issues:
- It can slow down your tests because you can't ensure that the API response comes back promptly.
- Network issues can cause failed attempts, and you can't control when they happen either.
- Often, you won't have any control over the data in the API, leading to non-deterministic responses.
When running end-to-end tests, you should always strive to test real behavior. But given all the potential issues using services that aren't under your control, it's more practical to mock API requests. This way, you control the responses you want to validate in the test.
For this article, we'll go through an example of how mocking helps create better end-to-end tests. The examples below use a simple currency conversion application I built called Cash Conversion.
This application accepts a few inputs and makes an HTTP request to a RESTful API. The API performs a calculation and returns its results in JSON format, showing the conversion on screen.
If there's an error on the server, it returns an unsuccessful response, and the app displays a generic error message.
Writing end-to-end tests for this application poses a few issues we have to tackle. First off, since currency exchange rates change frequently, the API response changes along with these updates. We cannot write tests that verify the conversion message that appears on-screen uses the JSON response from the API server.
Another place we can't reliably cover with tests is handling unexpected errors from the API. We can't know when the API returns an unsuccessful response unless we build it in, which isn't a good practice for a production application.
Here's where mocks are handy. For these scenarios, a mock catches the API requests when they occur during our tests. We can then tell the mock what we want it to return. It allows us to write sturdy tests that run the same every time.
Before diving into the examples, this article assumes you know how TestCafe works. If you're starting with TestCafe, please read the article How to Get Started with TestCafe to understand the basics of the framework.
Writing a test that talks to an external API
Looking at the Cash Conversion application, we see it expects three inputs before making a request. It needs an amount to convert from, the currency for that amount, and the currency we want to use for the conversion.
After setting these three fields, we can convert the entered amount by clicking on the "Convert" button. Clicking on this button triggers an asynchronous HTTP request to our external API server with the entered parameters.
The API server handles the conversion on its end and then returns a JSON response with the submitted parameters and the converted amount according to the inputs. The application takes that information and displays the result on the screen.
When we test the application manually, we can test things out by converting 100 Euros to Japanese Yen. At the time of writing this article, the calculated conversion returned by the server for 100 Euros was about 11910.35 Japanese Yen. The JSON response returned by the server was the following:
{
"baseAmount": "100",
"fromCurrency": "Euro",
"toCurrency": "Japanese Yen",
"conversion": "11910.35"
}
Now that we know how the application works let's write a TestCafe test case covering this flow, starting with our page model class to cover the elements we'll refer to during our test. Create a new page_models
directory in our test suite directory, and inside we create our class in the home_page_model.js
file:
import { Selector } from "testcafe";
class HomePageModel {
constructor() {
this.baseAmountInput = Selector("#base_amount");
this.fromCurrencySelect = Selector("#from_currency");
this.fromCurrencyOptions = this.fromCurrencySelect.find("option");
this.toCurrencySelect = Selector("#to_currency");
this.toCurrencyOptions = this.toCurrencySelect.find("option");
this.convertButton = Selector("#convert_btn");
this.conversionResponse = Selector(".conversion-response");
}
}
With our elements defined, we can proceed with writing the scenario to verify converting an amount between two currencies. We'll create a new test file called conversion_test.js
in our test suite directory with our initial test:
import homePageModel from "./page_models/home_page_model";
fixture("Cash Conversion App").page("https://cash-conversion.dev-tester.com/");
test("User can convert between two currencies", async t => {
await t
.typeText(homePageModel.baseAmountInput, "100")
.click(homePageModel.fromCurrencySelect)
.click(homePageModel.fromCurrencyOptions.withText("Euro"))
.click(homePageModel.toCurrencySelect)
.click(homePageModel.toCurrencyOptions.withText("Japanese Yen"))
.click(homePageModel.convertButton);
await t.expect(homePageModel.conversionResponse.exists).ok();
await t
.expect(homePageModel.conversionResponse.innerText)
.eql("100 Euro is about 11910.35 Japanese Yen");
});
First, we start by importing our page model class. In the test fixture, our starting point is the Cash Conversion site, which contains a single page. For the test itself, we're telling TestCafe to fill out the base amount and select the currencies we want to use between conversions. Finally, the test clicks on the "Convert" button, and we make an assertion to validate the converted amount from the API is on screen.
If you don't fully understand how this test works, read the How to Get Started with TestCafe article for the basics.
When we run this test at this point, we'll see that it passes:
However, we'll run into a problem shortly. Exchange rates are constantly fluctuating, almost minute by minute. The API that performs the conversion updates its currency exchange rates every couple of hours. That means the conversion returns a different result whenever the API's data receives an update.
If we re-run the test after an update to the API's exchange rate data, most likely we'll find that our test now fails because the converted amount is now different:
There are a couple of ways to handle this issue from a test perspective. The quickest solution is to change the assertion in the test only to validate that the server responded without checking the response itself. This change makes the test pass, but it's not a very good test because we'll miss any issues with the API's response body. For instance, we wouldn't catch if the structure of the JSON response changed.
Another solution I've seen implemented is to create a mock API service that returns what you want to use in your tests. There are tools such as Mirage JS and JSON Server that create a quick API service for testing purposes. These tools are excellent for local testing, but it does require extra overhead to set up and maintain.
Using mocks directly in our tests lets us bypass the issues these potential solutions create. Instead of skipping crucial parts of our test runs or adding complexity to our testing pipeline, we can let TestCafe handle it directly.
Intercepting an HTTP request with TestCafe
Let's write a mock to intercept the HTTP request to the API server. That way, we can control the response and the converted amount from the server. Then we won't have to worry about our test failing when the API data gets updated. We'll have a consistent response every time we run our test.
import { RequestMock } from "testcafe";
import homePageModel from "./page_models/home_page_model";
fixture("Cash Conversion App").page("https://cash-conversion.dev-tester.com/");
const conversionMock = RequestMock()
.onRequestTo("https://cash-conversion-api.dev-tester.com/exchange_rates/convert")
.respond({
baseAmount: "100",
conversion: "11910.35",
fromCurrency: "Euro",
toCurrency: "Japanese Yen"
});
test.requestHooks(conversionMock)(
"User can convert between two currencies",
async t => {
await t
.typeText(homePageModel.baseAmountInput, "100")
.click(homePageModel.fromCurrencySelect)
.click(homePageModel.fromCurrencyOptions.withText("Euro"))
.click(homePageModel.toCurrencySelect)
.click(homePageModel.toCurrencyOptions.withText("Japanese Yen"))
.click(homePageModel.convertButton);
await t.expect(homePageModel.conversionResponse.exists).ok();
await t
.expect(homePageModel.conversionResponse.innerText)
.eql("100 Euro is about 11910.35 Japanese Yen");
}
);
In this test, we introduce the RequestMock
method. This constructor method allows us to call two additional methods: onRequestTo
and respond
.
The onRequestTo
method lets us specify which HTTP request we want to intercept so we can manage its response later. In this test, we want to catch the request to the API server. We can specify a string to the URL, a regular expression, or an object. If you don't know the URL of the API, you can use Chrome's DevTools or Firefox's Developer Tools to find the request when performing the action manually.
The respond
method is where we'll specify what we want to use as the response for testing purposes. Since we're expecting a JSON response, we tell our mock to return an object that has the same structure as the API's JSON response. You can also specify a string or function for other response types, like a plain text or HTML response.
To use the mock, we first set up the mock in a reference called conversionMock
. Once set up, we need to tell our test to use this mock to keep an eye out for any HTTP requests that match what we specified in the onRequestTo
method. We can achieve this by attaching the mock in a hook, using the requestHooks
method.
We can add the request hook to either the fixture for all tests, or to a single test. In the updated test, the mock is attached to the test since we'll use different mocks in another test later. For now, let's run our test with our mock and see the result. Unfortunately, the changes we made to our test aren't enough to make it pass:
If you notice, the error message is entirely different from before. The assertion failed, but it's because of the warning message that appears after running the test. Where did the warning come from all of a sudden? It occurred because of the way browsers handle asynchronous connections across domains. This mechanism is also known as CORS. CORS is a security mechanism handled by your web browser that allows one application in a domain to specify who can access its resources.
On a basic level, the way CORS works is by performing an initial request to the API before our desired API request, known as a preflight request. The preflight request verifies if the originating server where the request is coming from is allowed to make a cross-domain request. If it is, then there it returns a successful response with a few headers that tell the browser it's okay to make further API requests.
I won't go into the nitty-gritty of CORS, but you can find more information at MDN, or leave a comment below if you have any questions.
In our test, we're mocking the API request, which goes to a different domain. Even with our mock, the browser still goes through its usual CORS check. However, our mock only returns a response body, and it's not returning any headers. The lack of appropriate headers makes the browser running the test think the CORS check failed, leading to the JavaScript error and our failed test.
Thankfully, fixing this error is straightforward. All we need to do is return the necessary headers to trick the browser into thinking the CORS check passed, so no errors occur. The headers that the browser needs for its CORS check vary depending on your server's configuration. In the case of the Cash Conversion application, we just need the Access-Control-Allow-Origin
header. Let's update the mock:
import { RequestMock } from "testcafe";
import homePageModel from "./page_models/home_page_model";
fixture("Cash Conversion App").page("https://cash-conversion.dev-tester.com/");
const conversionMock = RequestMock()
.onRequestTo("https://cash-conversion-api.dev-tester.com/exchange_rates/convert")
.respond(
{
baseAmount: "100",
conversion: "11910.35",
fromCurrency: "Euro",
toCurrency: "Japanese Yen"
},
200,
{ "Access-Control-Allow-Origin": "https://cash-conversion.dev-tester.com" }
);
test.requestHooks(conversionMock)(
"User can convert between two currencies",
async t => {
await t
.typeText(homePageModel.baseAmountInput, "100")
.click(homePageModel.fromCurrencySelect)
.click(homePageModel.fromCurrencyOptions.withText("Euro"))
.click(homePageModel.toCurrencySelect)
.click(homePageModel.toCurrencyOptions.withText("Japanese Yen"))
.click(homePageModel.convertButton);
await t.expect(homePageModel.conversionResponse.exists).ok();
await t
.expect(homePageModel.conversionResponse.innerText)
.eql("100 Euro is about 11910.35 Japanese Yen");
}
);
The respond
method for our mock now contains two additional parameters along with the response body we already had. The second parameter accepted by the method is the HTTP status code we want for our mock response. We'll cover this parameter later in the article. The third parameter for the method is an object of the response headers we want to simulate from the API. Here's where we place our necessary header for the CORS check.
With these changes in place, we can re-run our test, and we'll have a passing test once again:
Simulating API errors
Now that we know how to use mocks for simulating a successful response let's cover another example to test our when the API fails. Generally, good end-to-end testing practices focus on covering the "happy path" in your end-to-end tests. However, it's also good practice to cover a few failure scenarios, especially those that impact the user experience in your application.
In services like an API, there are plenty of reasons why a request can fail, from a hiccup in network connectivity to the server's database going offline. Testing these kinds of scenarios, especially involving external services such as making an API request to another server, is almost impossible to do. You can't make an error pop up upon request - that means there's a bug that needs fixing.
For the Cash Conversion application, successful API requests receive an HTTP status code of 200 OK
along with a response body. We used this status code to make our current test pass.
If there's something wrong on the server for the API and it can't process its response successfully, it returns a different status code with no response body. The application sees this non-200 HTTP status code and renders a general error message indicating something went wrong. Note that in a real-world application, you would most likely get descriptive error messages pointing out the exact problem. For these examples, we'll just check the status code for failures.
We already saw that we could simulate the status code in the intercepted HTTP request using mocks in TestCafe. Let's create a new mock that simulates this failure, and attach it to a new test that verifies the error message shows up as expected.
First, make sure to include the relevant selectors in the page model class, mainly the error message element:
import { Selector } from "testcafe";
class HomePageModel {
constructor() {
this.baseAmountInput = Selector("#base_amount");
this.fromCurrencySelect = Selector("#from_currency");
this.fromCurrencyOptions = this.fromCurrencySelect.find("option");
this.toCurrencySelect = Selector("#to_currency");
this.toCurrencyOptions = this.toCurrencySelect.find("option");
this.convertButton = Selector("#convert_btn");
this.conversionResponse = Selector(".conversion-response");
this.errorResponse = Selector(".error-message");
}
}
We can now proceed with our new mock and test:
import { RequestMock } from "testcafe";
import homePageModel from "./page_models/home_page_model";
fixture("Cash Conversion App").page("https://cash-conversion.dev-tester.com/");
const conversionMock = RequestMock()
.onRequestTo("https://cash-conversion-api.dev-tester.com/exchange_rates/convert")
.respond(
{
baseAmount: "100",
conversion: "11910.35",
fromCurrency: "Euro",
toCurrency: "Japanese Yen"
},
200,
{ "Access-Control-Allow-Origin": "https://cash-conversion.dev-tester.com" }
);
const errorMock = RequestMock()
.onRequestTo("https://cash-conversion-api.dev-tester.com/exchange_rates/convert")
.respond("", 422, {
"Access-Control-Allow-Origin": "https://cash-conversion.dev-tester.com"
});
test.requestHooks(conversionMock)(
"User can convert between two currencies",
async t => {
await t
.typeText(homePageModel.baseAmountInput, "100")
.click(homePageModel.fromCurrencySelect)
.click(homePageModel.fromCurrencyOptions.withText("Euro"))
.click(homePageModel.toCurrencySelect)
.click(homePageModel.toCurrencyOptions.withText("Japanese Yen"))
.click(homePageModel.convertButton);
await t.expect(homePageModel.conversionResponse.exists).ok();
await t
.expect(homePageModel.conversionResponse.innerText)
.eql("100 Euro is about 11910.35 Japanese Yen");
}
);
test.requestHooks(errorMock)(
"User sees an error message if the API request has a non-success response",
async t => {
await t
.typeText(homePageModel.baseAmountInput, "100")
.click(homePageModel.fromCurrencySelect)
.click(homePageModel.fromCurrencyOptions.withText("Euro"))
.click(homePageModel.toCurrencySelect)
.click(homePageModel.toCurrencyOptions.withText("Japanese Yen"))
.click(homePageModel.convertButton);
await t.expect(homePageModel.errorResponse.exists).ok();
await t
.expect(homePageModel.errorResponse.innerText)
.eql("There was an error performing the conversion. Please try again.");
}
);
There are a few new additions to our test file. First, we create a new reference called errorMock
and set up our mock as we did before. The main difference is the response we want the mock to return in the respond
method.
As mentioned above, error responses from the Cash Conversion API return an empty body, so we can set an empty string as the first parameter in the method to indicate an empty body.
For our status code, anything that's not in the 200-range triggers the error response. Often, the error response is either 422 Unprocessable Entity
if there's a problem with the parameters for the conversion, or 500 Internal Server Error
if there's an issue with the server. In this test, we set the status code to return a 422 Unprocessable Entity
status code. The third parameter for our response headers remains the same to handle CORS validation.
Our test runs through the same steps as the successful test scenario. Since we're attaching our failure mock to the test, the assertion checks for the existence of the error message. When we run both tests, we can confirm the mocks are doing their jobs for each test:
The source code for these examples is available on GitHub.
Mocks aren't a silver bullet
Hopefully, you can see how useful mocks are when running tests involving other services. When used appropriately, it saves a lot of effort and frustration in interacting with systems that aren't under our control.
However, while mocking is useful, don't get tempted to use mocks for every scenario similar to this one. Mocking can hide bugs since it bypasses real behavior. For instance, if the implementation of the API changes, like returning a different response body, your test can happily pass while the application might not work as expected. Mocks can also make tests brittle by breaking the test with simple changes. You'll need to keep mocks up to date to avoid these problems.
The ideal scenario is to test real behavior as much as possible. Always try first to perform tests against the real systems. Once you observe potential issues like the ones we covered in this article, you can begin introducing mocks to your tests. That way, you won't go overboard with simulating real behavior and masking potential trouble down the road.
If you find yourself relying on mocks too much to have a passing test suite, take it as a sign that there's too much complexity in the test or the application under test. Talk it over with your team to consider refactoring the tests or the application itself to improve software testability.
Summary
When you're testing against systems you don't manage, such as a RESTful API from a third-party, it's challenging to ensure consistency between test runs. To overcome these issues, we can use mocks to simulate their behavior. Many testing tools these days have built-in support for creating mocks, or mocking libraries that easily integrate into the tool itself.
TestCafe's mocking mechanism can intercept any HTTP request and have it return what you want for your test. It's handy for maintaining a consistent response in your test execution. You can also control other factors such as status codes and headers for additional control over the simulated response. It allows you to test different scenarios, like how your application handles errors.
Mocks are practical for many use cases, but you should use them sparingly as they can conceal errors in real-world usage. Mocks are best used when a particular test can't run in a deterministic way or for challenging scenarios to test, like simulating a server error.
With the appropriate use of mocks, you can write stable tests without worrying about potential issues from systems that are out of your control.
Do you or your team use mocks? How have they helped you maintain a reliable and stable test suite? Share your usage and any tips on using mocks by leaving a comment below!
P.S. Was this article helpful to you? I'm currently writing a book that will cover much more about about the TestCafe testing framework.
With the End-to-End Testing with TestCafe book, you will learn how to use TestCafe to write robust end-to-end tests on a real web app and improve the quality of your code, boost your confidence in your work, and deliver faster with less bugs.
For more information, go to https://testingwithtestcafe.com. Sign up and stay up to date!
Top comments (0)