🔄 Automatically record and playback HTTP requests made in Cypress tests.
Cypress Playback is a plugin and a set of commands that allows Cypress to automatically record responses to network requests made during a test run. These responses are then saved to disk and made available for playback in later test runs. This allows for applications or components under test to always receive the same response to a request, no matter where or when they run.
This plugin is not a replacement for the cy.intercept
command, but is
instead a wrapper around that command. It handles situations where a developer
isn't concerned with the contents of a response to a network request, just that
the response is always the same.
This README contains the full documentation for Cypress Playback but this quick start will get you going in just a few minutes.
Step 1. In a project with Cypress installed, run:
npm install @oreillymedia/cypress-playback -D
Step 2. Add the tasks to the project's setupNodeEvents
in cypress.config.js
file
(Cypress plugin usage docs):
setupNodeEvents(on, config) => {
require('@oreillymedia/cypress-playback/addTasks')(on, config);
...
})
Step 3. Add the commands to the project's cypress/support/commands.js
file:
import '@oreillymedia/cypress-playback/addCommands';
The playback
command is a wrapper around the Cypress' intercept
command
and can be used in much the same way.
A notable exception is that it doesn't provide any way to attach a request
handler or a provide a static response, as that isn't the purpose of this
plugin. This plugin is designed to capture real responses and record them for
later playback. For providing fixtures as responses, the normal cy.intercept
command should be used.
// Records or plays back network responses, depending on the value of
// `PLAYBACK_MODE` in the Cypress environment.
cy.playback(method, url, playbackOptions);
cy.playback(method, routeMatcher, playbackOptions);
// Capturing a request.
cy.playback('POST', \/users\/);
// Providing playback options.
cy.playback('GET', \/todos\/, { toBeCalledAtLeast: 2 });
// Aliasing the request for later use in the test.
cy.playback('GET', 'https://www.example.com/300/150').as('image');
Run Cypress as you normally would using cypress open
. By default using
cypress open
will operate Cypress playback in hybrid mode,
meaning it will save any requests that have not already been saved and
fulfill ones that have and as such, is a great place to start.
That's it! Your specified playback
URL calls are now stored as static
fixtures and will be automatically fulfilled in further test runs.
Keep reading for further info on the various playback modes and playback API.
Capture requests using this specific HTTP method (GET
, POST
, etc).
🚨 NOTE: Unlike the
intercept
command, the command requires amethod
argument.
Capture requests that match this value. See the intercept
command's "Matching
url" documentation for more details.
An object used to define a request that can be captured. See the intercept
command's "RouteMatcher" documentation for more details.
playbackOptions
is an object used to modify the behavior of the playback
command. The example object below is showing the default values for all
available properties.
{
allowAllStatusCodes: false,
toBeCalledAtLeast: 1,
matching: {
ignores: {
attributes: [],
bodyProperties: [],
searchParams: []
}
},
rewriteOrigin: undefined
}
More detailed examples of how to use these properties can be found in the "Requests and Responses" section below.
By default, the command will only record responses that have a 2xx
status
code. By setting this to true
, all responses will be recorded.
Note that trying to record 3xx
responses will lead to some strange behavior
and is area where more work is needed in the plugin.
Default: false
The minimum number of times the system under test must trigger a network request
that matches the url
or routeMatcher
. See "All Requests Complete"
Assertions for more details.
Default: 1
- At least 1 request must have been matched.
This object modifies how the command tries to match recorded responses to a network request.
Default: undefined
This property supports two different value types:
-
string[]
: An array of network request attributes to ignore. See theattributes
entry below. -
object:
An object consisting of one or more of the following properties:-
attributes
: A string array consisting of one or more of the following values.protocol
hostname
port
pathname
search
method
body
-
bodyProperties
: A string array consisting of properties in the network request body to ignore. This assumes that the body is a JSON object. -
searchParams
: A string array consisting of search parameters on the network request url to ignore.
-
This string will be used in place of the current origin found in the network request's URL.
Default: undefined
The command yields the response of the cy.intercept
command it is wrapping.
See the intercept
command's "Yields" section for more details.
Since the words "request" and "response" can have a few meanings, it's helpful to provide a few definitions first:
- Request: A network request made by the browser that may be intercepted by a request matcher.
- Response: The response sent back to the browser's network request. The plugin saves responses to the automatically created fixture file.
- Request Matcher: This is what is being created by calling the
cy.playback
command. Request matchers are saved to the automatically created fixtures file. Depending on how the route matching is setup in the matcher, the plugin may record multiple responses for a single matcher.
🚨 A Warning on Secrets: A response that is recorded in the fixtures file, which will likely be committed to the project's repository, will contain both the request's and response's headers and body. While this file is binary and is compressed, it isn't encrypted or protected in any way. When recording responses, make sure there aren't any secrets used by a request or returned in a response that you wouldn't want exposed.
Since a developer can create a request matcher that can potentially match multiple requests, it's important that the plugin know how to return the right response to any individual request. That means the plugin is performing both request matching and response matching.
To explain this better, consider the examples below.
The developer wants to record all requests made to /api/v1/todo/
. Their app
will be making multiple calls to this Api, each with an id value appended to the
end. The developer sets up the following playback
command in their test:
cy.playback('GET', new RegExp('/api/v1/todo/'));
Internally, the plugin is calling cy.intercept
with that RegExp as the route
matcher. The app, over the course of the test, makes requests to
/api/v1/todo/1
through /api/v1/todo/5
. The plugin's wrapped intercept
command intercepts all of those requests and its custom request handler is
called with the full details for each request that is being made. For example,
that means the request handler will see information such as a request to
https://example.com/api/v1/todo/1
was made.
What happens next depends on what mode the plugin is in:
- If the plugin is in "record" mode, it will capture the response and write it to disk when the test is completed.
- If the plugin is in "playback" mode, it will try to find a previously recorded response for that specific request.
More details on the plugin's mode can be found below.
The developer is recording requests made to their local instance, which is
hosted at http://localhost:4200
. As it runs, the app will be making requests
to http://localhost:4200/api/v1/todo/
. However, in the project's CI job, the
tests will be run in a Docker container, so the app will be hosted at
http://test:8000
.
This is a problem, because by default the plugin expects every attribute of the request to match. The attributes that must match are:
protocol
hostname
port
pathname
search
method
body
If any of those are different, the recorded response won't be returned. However,
the matching.ignores
property allows the developer to specifically say certain
attributes shouldn't be considered.
ℹ️ Note that while headers are recorded and played back, they are not used when trying to look up a matching response. This is because headers tend to vary considerably, so they are always ignored. However, functionality could be added to the plugin to allow a developer to specify which headers to include when matching.
In this case, since the hostname
and port
are going to be different, the
developer should write their playback
command like this:
cy.playback('GET', new RegExp('/api/v1/todo/'), {
matching: { ignores: ['hostname', 'port'] }
});
Of course, there is a danger that if too many attributes of a request are ignored, the plugin won't find the correct response. For example, if every request attribute were ignored, then every recorded response would be a match. It's best to limit the list of ignored attributes to smallest number possible.
The application under test is making a POST
request to an endpoint. The body
of this network request contains a timestamp:
{
"when": {
"timestamp": "2022-02-01T14:43:10.023Z"
}
}
In addition, because the backend developer was malicious, the url for the network request must also contain a search parameter that includes the current date:
https://example.com/api/v1/access?current_date=2022-02-01
In this case, though, the developer can tell the playback command to ignore both that property and the search parameter when trying to find a matching response:
cy.playback('POST', new RegExp('/api/v1/access'), {
matching: {
ignores: {
bodyProperties: ['when.timestamp'],
searchParams: ['current_date']
}
}
});
As seen above, the values in the bodyProperties
array are strings defining
where the property can be found in the object. The example below provides
examples of supported paths.
const example = {
foo: "value",
bar: {
baz: "value",
qux: [
{
'Some whitespace': {
quux: "value"
}
}
]
}
};
// Paths to some of the properties above.
const bodyProperties = [
'foo',`
'bar.baz',`
'bar.qux.0["Some whitespace"].quux',`
];
Note that arrays are supported, but the indices are not surrounded by brackets. In addition, there is currently no concept of a wildcard that would cause a property to be removed from all object entries in an array.
The application under test is only making a certain network request once during
the test run. In addition, the developer is not concerned with any dynamic
values that may be found in the request. In such a case, the matching.anyOnce
property can be used:
cy.playback('POST', new RegExp('/api/v1/access'), {
matching: { anyOnce: true }
});
As its name implies, the anyOnce
property expects there to only be one
response recorded for a request matcher. During playback, it expects only a
single network request will match that request matcher and, in that case, it
provides the recorded response. If it ever tries to handle more than one network
request, an error is thrown an the test will fail.
The advantage of this property is that it can greatly simplify setting up
response matching. In these cases, the developer doesn't have to pull apart the
request and provide the dynamic elements to the playback
command.
The plugin can be run in one of three different modes:
playback
- Previously recorded responses are played back. If a matching response is not found for the intercepted request, an error is thrown and the test will fail. This is the default mode when Cypress is started with therun
option.record
- All request responses are recorded and any previously recorded responses are ignored.hybrid
- If a previously recorded response matches the intercepted request, the plugin plays back that response. Otherwise, the response is recorded when the request completes. This is the default mode when Cypress is started with theopen
option.
The mode can be overridden through an environment variable:
CYPRESS_PLAYBACK_MODE=record npx cypress open
It can also be set in the cypress.json
:
{
"env": {
"PLAYBACK_MODE": "record"
}
}
During the afterEach
stage of a test, the plugin will assert that all request
matchers were matched a minimum number of times. The default number of times is
1
, but that value can be changed through the toBeCalledAtLeast
option.
// This request matcher is considered optional, so don't fail the test. There
// is a danger in this, though. See the "Why This is Important" section below.
cy.playback('GET', new RegExp('/api/v1/todo/'), { toBeCalledAtLeast: 0 });
// This request matcher must be matched 5 times, or the test will fail.
cy.playback('GET', new RegExp('/api/v1/todo/'), { toBeCalledAtLeast: 5 });
What the plugin is doing during the afterEach
stage is, over a period of time,
checking to see if any request matchers still have not been matched their
minimum number of times. If after 10 seconds, the expected number of requests is
still not met, the plugin fails the test.
There are two factors that make this assertion important. One factor is that Cypress may consider a test complete before all the network requests made by the system under test are received. The other factor is that the order in which network requests complete is non-deterministic. This means that during one test run one request may complete before Cypress considers the test does, while in another run that same request has not.
To handle that, the plugin tries to make sure every playback
command has
received at least one response that can be recorded. This is to ensure that all
request matchers have responses available when the test is run in "playback"
mode.
All of this is to explain why an "optional" request matcher can be dangerous.
Setting toBeCalledAtLeast
to 0
tells the plugin that not receiving a request
matching that playback
command is fine. When the plugin is running in "record"
or "hybrid" mode, the test will not be failed because of this. However, when the
plugin is running in "playback" mode and a request is made that matches that
playback
command, the plugin returns a 404
. That may result in a flaky test
that can be hard to debug.
As applications and tests change over time, requests that were once made may not be in the future. To keep these old request matchers and responses from building up in the recorded fixtures file, the plugin considers any request matcher or response loaded from disk as "stale". These stale entities are automatically removed when the fixtures file is written to disk.
A "stale" entity becomes "fresh" when...
- Request matchers are considered "fresh" if a
cy.playback
command is invoked in the test that matches theirmethod
,url
orrouteMatcher
, and playback options. - Responses are considered "fresh" if a single request is made that exactly
matches the request attributes they match on. Meaning, a request was made that
matches the
post
,hostname
, etc. they match on.
All the captured responses for a test are grouped together into a single
.cy-playbacks
file that is saved to the Cypress fixtures folder. A subdirectory
is created in the fixtures folder for each spec file. Within that folder, a
fixture file is created for each test in the spec file. The file name is a
combination of the test's name and any describe
blocks it is nested under.
Consider the following example spec file.
- File Name:
./cypress/integration/app/basic.spec.js
- Spec File Contents:
describe('app', () => { it('works', () => { // Test code. }); it('still works', () => { // Test code. }); describe('another language', () => { it('works', () => { // Test code. }); }); });
- Generated Fixture Files:
./cypress/fixtures/app/basic-spec/app-works.cy-playbacks ./cypress/fixtures/app/basic-spec/app-still-works.cy-playbacks ./cypress/fixtures/app/basic-spec/app-another-language-works.cy-playbacks
⚠️ As can seen, if the location of the spec file or the structure or name of the test changes, the generated file location and name will change as well. This means your project could end up with orphaned fixture files, as the plugin doesn't keep track of changes made to file names.
The .cy-playbacks
file is a binary file, which is created by JSON stringifying
the request matchers and requests and compressing that output with Node's
zlib.deflate
function. There are two reasons for this approach:
- As a compressed binary file it will take up less space on disk and Git LFS can be used to store them.
- The unreadable nature of the file prevents developers from easily being able to edit them. This is important, because the plugin overwrites the fixture file whenever a test completes, so any manual edits would be immediately lost.
This command can be used to allow conditional logic in a test when the plugin will playback recorded requests.
cy.isPlayingBackRequests();
cy.isPlayingBackRequests().then((isPlayingBack) => {
cy.log(`isPlayingBack: ${isPlayingBack}`);
});
None
cy.isPlayingBackRequests
yieldstrue
if the playback mode isplayback
orhybrid.
Otherwise, yieldsfalse
.
This command can be used to allow conditional logic in a test when the playback
mode is set to record
. For example, this could be used to perform a login step
that wouldn't be needed in playback mode.
cy.isRecordingRequests();
cy.isRecordingRequests().then((isRecording) => {
cy.log(`isRecording: ${isRecording}`);
});
None
cy.isRecordingRequests
yieldstrue
if the playback mode isrecord
orhybrid.
Otherwise, yieldsfalse
.