I recently had to use Cypress to test a page that had various dropdown options, and some of those options might be excluded if already selected. Being a UK Gov page flow, the user could only select a single option from the drop down, then click continue, and if they wanted to add another they would have to repeat the flow.
On the second time through, they should not see the previously selected option.
This actually presents another problem, which is what to do when all the available options have been selected? That is out of scope of this post. At the time of writing it’s back with the UX team for discussion 🙂
There were several tests that I wanted to validate on this journey, so as we have a bunch of things to cover, I suggest we dive right on in and start getting our hands dirty.
Example Form HTML
At its most basic, our form HTML looks like this:
<form method="post">
<div class="govuk-form-group">
<label class="govuk-label" for="sort">
Sort by
</label>
<select class="govuk-select" id="sort" name="sort">
<option value="published">Recently published</option>
<option value="updated" selected>Recently updated</option>
<option value="views">Most views</option>
<option value="comments">Most comments</option>
</select>
</div>
<button class="govuk-button" data-module="govuk-button">
Save and continue
</button>
</form>
Code language: HTML, XML (xml)
And because we like visuals, this is how things appear on the screen:
Example Express Route Config
The way this little example is set up is as follows:
// src/routes/index.ts
import express from "express";
const router = express.Router();
router.get("/", (req: express.Request, res: express.Response) => {
res.redirect("/check-your-answers");
});
router.get(
"/check-your-answers",
(req: express.Request, res: express.Response) => {
res.render("check-your-answers.njk", {
selections: req.session.selections,
});
},
);
router.get("/sort-by", (req: express.Request, res: express.Response) => {
res.render("index.njk");
});
router.post("/sort-by", (req: express.Request, res: express.Response) => {
req.session.selections = [...(req.session.selections || []), req.body.sort];
res.redirect("/check-your-answers");
});
router.get("/clear", (req: express.Request, res: express.Response) => {
req.session.selections = [];
res.redirect("/");
});
export default router;
Code language: TypeScript (typescript)
Landing Page
OK, so the site root (/
) will redirect us to /check-your-answers
.
I’ve highlighted that as lines 5-7 above.
router.get(
"/check-your-answers",
(req: express.Request, res: express.Response) => {
res.render("check-your-answers.njk", {
selections: req.session.selections,
});
},
);
Code language: TypeScript (typescript)
The /check-your-answers
page is going to render out our check-your-answers.njk
Nunjucks template.
As part of this res.render
call we pass in a single parameter of selections
.
This will be the full contents of whatever value we have stored in req.sessions.selections
.
Sessions Setup
We will use an in-memory session store for this example. Use something more robust in production.
// src/index.ts
import session from "express-session";
const app = express();
// Configure session middleware with the default in-memory store
app.use(
session({
secret: "some-highly-secret-key",
resave: false,
saveUninitialized: true,
}),
);
Code language: TypeScript (typescript)
The configuration for the session middleware is set up using app.use()
:
session()
is a function provided by “express-session” that takes an object as an argument. Within this object, three main configuration options are set:secret
: This is a security measure and serves as a secret key used to sign the session ID cookie. It should be kept highly confidential to enhance security.resave
: It is set tofalse
, which means that the session will not be saved back to the session store if there are no modifications during a request. This is a common performance optimisation.saveUninitialized
: It is set totrue
, which means that a session will be stored in the session store even if it’s not modified during a request. This is often used when you want to create sessions for all visitors, regardless of whether they are authenticated.
A Nicer Express Session Experience With TypeScript
I took the liberty of helping TypeScript to better understand what req.session.selections
may be, by providing an extension of the express-session
module:
// src/@types/express-session/index.d.ts
import "express-session";
type SortBy = "published" | "updated" | "views" | "comments";
declare module "express-session" {
interface SessionData {
selections?: SortBy[];
}
}
Code language: TypeScript (typescript)
The provided code extends the type definitions of the express-session
package in TypeScript. First, it imports the express-session
package to make its types accessible for extension. This is a really important step, which if you miss, will pretty much just not work.
Next, it defines a custom TypeScript type called SortBy
, which is a string literal union type. This type restricts variables to one of four specific string values: “published,” “updated,” “views,” or “comments.”
Then, using TypeScript’s declaration merging syntax, the code extends the “SessionData” interface within the express-session
module. The SessionData
interface represents the structure of session data that can be stored using express-session
. The extension adds a custom property called selections
to the interface, allowing it to hold an array of values that match the SortBy
type.
This is an optional value, so it may also be undefined
.
If you follow this approach, be sure to update your tsconfig.json
to include the line:
"typeRoots": ["./src/@types","./node_modules/@types"]
Code language: JSON / JSON with Comments (json)
Form Page
To render the form, and then handle the form submission, we have two more routes.
The first is our get
handler, which will take care of the content rendering:
router.get("/sort-by", (req: express.Request, res: express.Response) => {
res.render("index.njk");
});
Code language: TypeScript (typescript)
All the selections for the drop down exist in the index.njk
template.
For completeness, here’s how that template looks:
{% extends "layout.njk" %}
{% from "govuk/components/select/macro.njk" import govukSelect %}
{% from "govuk/components/button/macro.njk" import govukButton %}
{% block pageTitle %}[Cypress] Validate Dropdown Options Example<{% endblock %}
{% block content %}
<h1 class="govuk-heading-l">Cypress Validate Dropdown Options Example</h1>
<form method="post">
{{ govukSelect({
id: "sort",
name: "sort",
label: {
text: "Sort by"
},
items: [
{
value: "published",
text: "Recently published"
},
{
value: "updated",
text: "Recently updated",
selected: true
},
{
value: "views",
text: "Most views"
},
{
value: "comments",
text: "Most comments"
}
]
}) }}
{{ govukButton({
text: "Save and continue"
}) }}
</form>
{% endblock %}
Code language: Twig (twig)
Note that the form
does not have an action
which means it will implicitly post
back to the same route that it renders on.
In other words, it will implicitly post
to /sort-by
.
router.post("/sort-by", (req: express.Request, res: express.Response) => {
req.session.selections = [...(req.session.selections || []), req.body.sort];
res.redirect("/check-your-answers");
});
Code language: TypeScript (typescript)
And the post
handler simply receives the form submission, takes whatever value is posted in, and appends it to the req.session.selections
array.
Of course there is a bug in here, in that we can keep posting in the same value over and over, and we will end up with that value many times in our array. This was actually an existing bug in the implementation I was working on, so I have left it in for us to cover.
By the way, req.body.sort
only works because Express has been configured to use a body parser:
// src/index.ts
import bodyParser from "body-parser";
const app = express();
app.use(bodyParser.urlencoded({ extended: false }));
Code language: TypeScript (typescript)
Setting extended: false
means that body-parser
will use the simpler querystring
library to parse URL-encoded data in HTTP requests.
This is suitable for most basic form submissions.
If you have more complex data structures in your form or require advanced parsing capabilities, you can set extended: true
.
Either way, you have to set the extended
option or you get a warning printed to your console.
Helper Route
Lastly we have a route that only exists to help with this example.
Some variant of this would probably exist in a real project, such that we have a way to clean up once done. In this case we want to make sure the session is cleared off when the user has finished this part of the journey.
router.get("/clear", (req: express.Request, res: express.Response) => {
req.session.selections = [];
res.redirect("/");
});
Code language: TypeScript (typescript)
Actually you would probably be surprised how often this step is forgotten, and the session hangs around in a messy state. A real source of future bugs.
Validating Dropdown Options With Cypress
OK, example site setup. Let’s use Cypress now to test that our page renders and that we can see the expected options in our dropdown list.
describe("Validation of Dropdown Options example", () => {
it("Should validate each option and value in the 'Sort by' select", () => {
cy.visit("/sort-by");
cy.get("#sort")
.find("option")
.then(($options) => {
// Ensure there are exactly four options
expect($options).to.have.length(4);
// Check the first option
expect(Cypress.$($options[0]).text().trim()).to.equal(
"Recently published",
);
expect(Cypress.$($options[0]).val()).to.equal("published");
// Check the second option
expect(Cypress.$($options[1]).text().trim()).to.equal(
"Recently updated",
);
expect(Cypress.$($options[1]).val()).to.equal("updated");
// Check the third option
expect(Cypress.$($options[2]).text().trim()).to.equal("Most views");
expect(Cypress.$($options[2]).val()).to.equal("views");
// Check the fourth option
expect(Cypress.$($options[3]).text().trim()).to.equal("Most comments");
expect(Cypress.$($options[3]).val()).to.equal("comments");
});
});
Code language: TypeScript (typescript)
If your eyes are bleeding, don’t worry. I know this can be improved, but stick with me.
Personally I am not a massive fan of Cypress syntax. Dollar dollar y’all.
The $ symbol before the $options
variable name is … optional. But it is commonly used syntax that is meant to highlight that Cypress will give you a JQuery wrapped object:
cy.get("#sort")
.find("option")
.then(($options) => {
cy.log("what is $options?", $options);
Code language: TypeScript (typescript)
Which if we run the code with this additional logging statement, we get:
Anyway, let’s refactor that test to something a little less offensive.
First Refactoring
There’s a whole bunch of repetition in that test.
For each of the four expected entries in our dropdown, we do the same two steps:
- Check the text matches
- Check the value matches
A nicer way would be to define an array of objects and then loop over that array just the once.
it("Should validate each option and value in the 'Sort by' select", () => {
cy.visit("/sort-by");
// Define the expected options and their values
const expectedOptions = [
{ text: "Recently published", value: "published" },
{ text: "Recently updated", value: "updated" },
{ text: "Most views", value: "views" },
{ text: "Most comments", value: "comments" },
];
cy.get("#sort")
.find("option")
// Ensure there are exactly four options as expected
.should("have.length", expectedOptions.length)
// Iterate over each option and validate its text and value
.each(($option, index) => {
// Assert that the option's text matches the expected text
expect($option.text().trim()).to.equal(expectedOptions[index].text);
// Assert that the option's value matches the expected value
expect($option.val()).to.equal(expectedOptions[index].value);
});
});
Code language: TypeScript (typescript)
I’m sure this could likely be improved further, but as a first pass I am happy with this.
If all you’re interested in is validating a list of options in a dropdown, then at this point we are pretty much done.
However, in my case I had to take this further. So let’s keep going.
Selecting A Dropdown Option With Cypress
As a little detour here, I think the real project ticket I was set that led to me making set wasn’t quite right.
I think the drop down list would have been better served by being a checkbox component:
However on Gov UK projects there are usually many other considerations to account for, and it is not the developer’s place to dictate design.
Or to put it another way, my suggestion of swapping to check boxes was overruled.
So, a drop down list it had to be.
This meant the journey would be as follows:
- User initially has no selection
- User visits
/sort-by
and makes a selection - User clicks ‘Save and continue’
- User is taken to
/check-your-answers
which shows their current selection - User can click ‘Add’ which takes them to
/sort-by
- User can add another selection
- User clicks ‘Save and continue’
- User is taken to
/check-your-answers
which shows their current selection
Ultimately we should end up with a “Check your answers” page looking like this:
Let’s now create Cypress tests that allows us to add one, then multiple selections.
Select One Choice Journey
First we will test selecting one option, and seeing that the selected option is displayed on the “Check your answers” page.
it('Should select "Most views" and submit the form', () => {
cy.visit("/sort-by");
cy.get("#sort").select("Most views");
cy.get('button[data-module="govuk-button"]').click();
cy.url().should("match", /\/check-your-answers$/);
cy.contains(".govuk-summary-list__value", "views").should("exist");
});
Code language: TypeScript (typescript)
Here’s the template for check-your-answers.njk
:
{% extends "layout.njk" %}
{% from "govuk/components/back-link/macro.njk" import govukBackLink %}
{% from "govuk/components/summary-list/macro.njk" import govukSummaryList %}
{% from "govuk/components/button/macro.njk" import govukButton %}
{% block beforeContent %}
{{ govukBackLink({
text: "Back",
href: "/"
}) }}
{% endblock %}
{% block pageTitle %}Check Your Answers<{% endblock %}
{% set mySelections %}
{% if selections is defined and selections.length > 0 %}
{{ selections | join('<br>') | safe }}
{% else %}
No selections
{% endif %}
{% endset %}
{% block content %}
<h1 class="govuk-heading-l">Check your answers</h1>
{{ govukSummaryList({
rows: [
{
key: {
text: "Sort by selections"
},
value: {
html: mySelections
},
actions: {
items: [
{
href: "/sort-by",
text: "Add",
visuallyHiddenText: "add"
}
]
}
}
]
}) }}
{% endblock %}
Code language: Twig (twig)
And a quick recap of the route handler:
router.get(
"/check-your-answers",
(req: express.Request, res: express.Response) => {
res.render("check-your-answers.njk", {
selections: req.session.selections,
});
},
);
Code language: TypeScript (typescript)
This Cypress test runs and passes:
Another few types of duplication has begun to creep in here.
One is fairly obvious, the other less so.
Second Refactoring
Both of our tests expect the user to start by visiting the /sort-by
page.
We can extract this out to a beforeEach
step:
beforeEach(() => {
cy.visit("/sort-by");
});
it("Should validate each option and value in the 'Sort by' select", () => {
const expectedOptions = [
{ text: "Recently published", value: "published" },
{ text: "Recently updated", value: "updated" },
{ text: "Most views", value: "views" },
{ text: "Most comments", value: "comments" },
];
cy.get("#sort")
.find("option")
.should("have.length", expectedOptions.length)
.each(($option, index) => {
expect($option.text().trim()).to.equal(expectedOptions[index].text);
expect($option.val()).to.equal(expectedOptions[index].value);
});
});
it('Should select "Most views" and submit the form', () => {
cy.get("#sort").select("Most views");
cy.get('button[data-module="govuk-button"]').click();
cy.url().should("match", /\/check-your-answers$/);
cy.contains(".govuk-summary-list__value", "views").should("exist");
});
Code language: TypeScript (typescript)
That’s the obvious piece of duplication.
The less obvious one is the selectors we are using.
Both tests have a reference to #sort
. And the more tests we add, the more likely we are to add more such references.
Whilst only a mild annoyance at this stage, it isn’t that uncommon to want to rename things as the project grows. In this case we might need to do a find / replace on #sort
across the whole file, making for a larger diff that necessary when committing out change.
Perhaps a better approach would be to extract this #sort
selector out to a variable.
But for now, I won’t do this.
Select Multiple Choices Journey
Multiple choices?
Hey, did someone mention check boxes?
Err, yes. But that person was shot down 🙂
Never mind. Let’s add another Cypress test to capture the behaviour as dictated by the ticket.
it("Should allow multiple selections from the 'Sort by' select", () => {
cy.get("#sort").select("Most views");
cy.get('button[data-module="govuk-button"]').click();
cy.contains(".govuk-link", "Add").click();
cy.get("#sort").select("Most comments");
cy.get('button[data-module="govuk-button"]').click();
cy.contains(".govuk-summary-list__value", "views").should("exist");
cy.contains(".govuk-summary-list__value", "comments").should("exist");
});
Code language: TypeScript (typescript)
I struggled to come up with a better name for this test.
To me, if the naming of this test is hard, it is shouting about the behaviour being unusual. But I’ll stop whinging.
Again, these tests should now all pass:
With three tests all using a very similar set of selectors, we might now look to refactor further to reduce duplication.
Third Refactoring
There are a few approaches we could take here.
One is simply not to reduce the duplication. That is easiest, for now.
However, if you are still reading I’m going to assume you’re not that kind of person.
Variant #1 – Extracting Common Selectors
A first approach might be to extract out the selectors from being string literals, to variables.
Here’s how that might look:
describe("Validation of Dropdown Options example", () => {
// Reusable selectors
const SELECTORS = {
SORT_SELECT: "#sort",
SUMMARY_VALUE: ".govuk-summary-list__value",
ADD_BUTTON: ".govuk-link:contains('Add')",
SUBMIT_BUTTON: 'button[data-module="govuk-button"]',
};
beforeEach(() => {
cy.visit("/sort-by");
});
it("Should validate each option and value in the 'Sort by' select", () => {
const expectedOptions = [
{ text: "Recently published", value: "published" },
{ text: "Recently updated", value: "updated" },
{ text: "Most views", value: "views" },
{ text: "Most comments", value: "comments" },
];
cy.get(SELECTORS.SORT_SELECT)
.find("option")
.should("have.length", expectedOptions.length)
.each(($option, index) => {
expect($option.text().trim()).to.equal(expectedOptions[index].text);
expect($option.val()).to.equal(expectedOptions[index].value);
});
});
it('Should select "Most views" and submit the form', () => {
cy.get(SELECTORS.SORT_SELECT).select("Most views");
cy.get(SELECTORS.SUBMIT_BUTTON).click();
cy.url().should("match", /\/check-your-answers$/);
cy.contains(SELECTORS.SUMMARY_VALUE, "views").should("exist");
});
it("Should allow multiple selections from the 'Sort by' select", () => {
cy.get(SELECTORS.SORT_SELECT).select("Most views");
cy.get(SELECTORS.SUBMIT_BUTTON).click();
cy.get(SELECTORS.ADD_BUTTON).click();
cy.get(SELECTORS.SORT_SELECT).select("Most comments");
cy.get(SELECTORS.SUBMIT_BUTTON).click();
cy.contains(SELECTORS.SUMMARY_VALUE, "views").should("exist");
cy.contains(SELECTORS.SUMMARY_VALUE, "comments").should("exist");
});
});
Code language: TypeScript (typescript)
Some people find UPPER_CASE variables like this to be very shouty. Personally I quite like this approach.
However, if you’re not a fan, here’s a different way to tackle it:
At a glance, this next approach may seem more preferable:
// Reusable function to select an option and submit the form
function selectOptionAndSubmit(optionText: string) {
cy.get("#sort").select(optionText);
cy.get('button[data-module="govuk-button"]').click();
cy.url().should("match", /\/check-your-answers$/);
cy.contains(".govuk-summary-list__value", optionText).should("exist");
}
describe("Validation of Dropdown Options example", () => {
beforeEach(() => {
cy.visit("/sort-by");
});
it("Should validate each option and value in the 'Sort by' select", () => {
const expectedOptions = [
{ text: "Recently published", value: "published" },
{ text: "Recently updated", value: "updated" },
{ text: "Most views", value: "views" },
{ text: "Most comments", value: "comments" },
];
cy.get("#sort")
.find("option")
.should("have.length", expectedOptions.length)
.each(($option, index) => {
expect($option.text().trim()).to.equal(expectedOptions[index].text);
expect($option.val()).to.equal(expectedOptions[index].value);
});
});
it('Should select "Most views" and submit the form', () => {
selectOptionAndSubmit("Most views");
});
it("Should allow multiple selections from the 'Sort by' select", () => {
selectOptionAndSubmit("Most views");
cy.contains(".govuk-link", "Add").click();
selectOptionAndSubmit("Most comments");
});
});
Code language: TypeScript (typescript)
Far less shouty, and quite descriptive.
However I find this sort of thing becomes harder to work with, the more tests you add.
Filtering Selected Options
Here’s where things get a little more interesting, and a little more off course.
As covered already, I don’t think a select
is the right approach to neatly solve this problem.
However, the ticket I was given asked that any ‘already selected options’ be filtered from the view when next visiting /sort-by
.
This presents a problem in the form of what to do when all four options are selected?
I’m not even going to try to answer that here. That’s one of my main gripes with this approach.
But the preceding piece, the filtering of options, is quite a fun technical challenge to solve. So let’s solve it.
Updating The Existing Test
We have a Cypress test to use as our starting point:
it("Should allow multiple visits to the #sort select list", () => {
cy.get("#sort").select("Most views");
cy.get('button[data-module="govuk-button"]').click();
cy.contains(".govuk-link", "Add").click();
// additional stuff here
cy.get("#sort").select("Most comments");
cy.get('button[data-module="govuk-button"]').click();
cy.contains(".govuk-summary-list__value", "views").should("exist");
cy.contains(".govuk-summary-list__value", "comments").should("exist");
});
Code language: TypeScript (typescript)
What we need to do is validate that on the second visit to /sort-by
, that the originally selected “Most views” is not even available for selection in the drop down list.
Whether you choose to solve the issue in code first, then update the test, or vice versa, I shall leave up to you.
As the section title says above, my preference would be to update the test first and see it fail.
Here’s a very long-winded stab at what we are going for:
it.only("Should allow multiple selections from the 'Sort by' select", () => {
cy.get("#sort").select("Most views");
cy.get('button[data-module="govuk-button"]').click();
cy.contains(".govuk-link", "Add").click();
const expectedOptions = [
{ text: "Recently published", value: "published" },
{ text: "Recently updated", value: "updated" },
{ text: "Most comments", value: "comments" },
];
cy.get("#sort")
.find("option")
.should("have.length", expectedOptions.length)
.each(($option, index) => {
expect($option.text().trim()).to.equal(expectedOptions[index].text);
expect($option.val()).to.equal(expectedOptions[index].value);
});
const expectedMissingOptions = [{ text: "Most views", value: "views" }].map(
({ value }) => value,
);
cy.get("#sort")
.find("option")
.each(($option) => {
expect($option.val()).not.to.be.oneOf(expectedMissingOptions);
});
cy.get("#sort").select("Most comments");
cy.get('button[data-module="govuk-button"]').click();
cy.contains(".govuk-summary-list__value", "views").should("exist");
cy.contains(".govuk-summary-list__value", "comments").should("exist");
});
});
Code language: TypeScript (typescript)
Which should fail right now because four elements will always exist in the drop down list, as they are hardcoded in the template:
OK, so to make this pass we need to do a more involved bit of coding.
The specifics of this are really beyond the scope of this post, so I will show the end result rather than the incremental way in which I ended up there.
The idea here is that we take an initial list: our four options.
Then we build up a selection of values from that list. Those values are stored in an array. We already have that. We are storing the values in the req.session.selections
array.
Then, we need to filter out the values from the initial list that exist in the user selected list.
Because this is fairly involved, and therefore prone to the sort of mistakes that would leave me red faced, I would definitely extract this logic out to a separate function, and use unit testing to ensure the accuracy of my work.
In order to do that, we need Jest.
Installing & Configuring Jest with Cypress
First we add the necessary dependencies:
npm i -D jest @types/jest ts-jest
Code language: Shell Session (shell)
The command npm i -D jest @types/jest ts-jest
is used to install specific packages related to testing in a Node.js project. Here’s what each package does:
jest
: Jest is a popular JavaScript testing framework that is often used for testing JavaScript and TypeScript code. It provides a test runner, assertion library, and various utilities for testing. When you installjest
as a development dependency (-D
or--save-dev
), it makes Jest available for running tests in your project.@types/jest
: This package contains TypeScript type definitions for Jest. When you write TypeScript code that uses Jest for testing, having type definitions for Jest is essential for TypeScript to understand and provide accurate type checking for your test code. Installing@types/jest
ensures that TypeScript can work seamlessly with Jest.ts-jest
:ts-jest
is a package that allows you to run TypeScript code with Jest. It’s a Jest preprocessor for TypeScript, which means it compiles your TypeScript code into JavaScript on the fly during testing. This enables you to write your tests in TypeScript while still using Jest’s testing capabilities.
Next, we initialise a new config file for Jest:
npx ts-jest config:init
Code language: CSS (css)
This command generates a jest.config.js
file in your project’s root directory (if one doesn’t already exist).
Here’s the contents:
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};
Code language: JavaScript (javascript)
Then we can add a new script
to our package.json
:
"scripts": {
...
"test": "jest"
},
Code language: JSON / JSON with Comments (json)
This will allow us to run npm run test
and run our unit tests.
In order to make Jest play nicely with Cypress, we will need two tsconfig.json
files. One in our project root (we already have this), and a new one in the cypress
directory.
Our tsconfig.json
in the project root directory needs updating:
{
"compileOnSave": true,
"compilerOptions": {
// .. other stuff
},
"include": ["**/*.ts"],
"exclude": ["node_modules","cypress","cypress.config.ts"]
}
Code language: JSON / JSON with Comments (json)
Be sure to add in these lines or Jest will not work properly.
You can then copy / paste your tsconfig.json
from the project root dir to the cypress
directory, but remember to remove those two lines from that new file.
After this, Jest and Cypress should play nicely together.
Extracting Options Display Logic
Because the logic for how our available options will work is now far more complex, I will extract out the code to its own ‘service’.
Here’s the unit tests:
// src/service/sort-by-options.test.ts
import sortByOptions, { initialOptions, SortByOption } from "./sort-by-options";
describe("service/sort-by-options", () => {
it("should return all the initial options if no selection is given", () => {
expect(sortByOptions()).toEqual(initialOptions);
});
it("should return an empty array if allOptions is explicitly empty", () => {
expect(sortByOptions({ allOptions: [] })).toEqual([]);
});
it("should return the expected array if allOptions and selectedOptions are explicitly empty", () => {
expect(sortByOptions({ allOptions: [], selectedOptions: [] })).toEqual([]);
});
it("should return all the given initial options if selectedOptions is explicitly empty", () => {
const given = [initialOptions[0], initialOptions[2]];
expect(
sortByOptions({
allOptions: given,
}),
).toEqual(given);
});
it("should return filtered options when selectedOptions is provided", () => {
const selectedOptions: SortByOption["value"][] = ["published", "updated"];
const expectedOptions: SortByOption[] = [
{
value: "views",
text: "Most views",
},
{
value: "comments",
text: "Most comments",
},
];
expect(sortByOptions({ selectedOptions })).toEqual(expectedOptions);
});
});
Code language: TypeScript (typescript)
And the implementation:
// src/service/sort-by-options.ts
// Define a type for the different sorting options available.
export type SortByOption =
| {
value: "published";
text: "Recently published";
}
| {
value: "updated";
text: "Recently updated";
}
| {
value: "views";
text: "Most views";
}
| {
value: "comments";
text: "Most comments";
};
// Define an initial set of sorting options.
export const initialOptions: SortByOption[] = [
{
value: "published",
text: "Recently published",
},
{
value: "updated",
text: "Recently updated",
},
{
value: "views",
text: "Most views",
},
{
value: "comments",
text: "Most comments",
},
];
// Define a type for the configuration options of the sortByOptions function.
type SortByOptions = {
allOptions?: SortByOption[]; // An array of all sorting options.
selectedOptions?: SortByOption["value"][]; // An array of selected sorting option values.
};
// Define a function called sortByOptions that takes configuration options.
const sortByOptions = ({
allOptions = initialOptions, // Use initialOptions as the default for allOptions.
selectedOptions = [], // An empty array as the default for selectedOptions.
}: SortByOptions = {}): SortByOption[] => {
// If no sorting options are selected, return all available options.
if (selectedOptions.length === 0) {
return allOptions;
}
// Filter out options that are not selected.
return allOptions.filter(
(option) =>
!selectedOptions.some(
(selectedOption) => selectedOption === option.value,
),
);
};
// Export the sortByOptions function as the default export.
export default sortByOptions;
Code language: JavaScript (javascript)
I feel like a nicer approach might be possible here. I hate negation like in that filter
. I find it so confusing. But I wasn’t able to come up with a better one, and that one passes the tests.
Updating The Handler For Sort By
With this new service available, we can now update the get
route handler:
router.get("/sort-by", (req: express.Request, res: express.Response) => {
const availableOptions = sortByOptions({
selectedOptions: req.session.selections ?? [],
});
res.render("index.njk", {
availableOptions,
});
});
Code language: TypeScript (typescript)
And we will need to update the template accordingly:
{% extends "layout.njk" %}
{% from "govuk/components/select/macro.njk" import govukSelect %}
{% from "govuk/components/button/macro.njk" import govukButton %}
{% block pageTitle %}[Cypress] Validate Dropdown Options Example<{% endblock %}
{% block content %}
<h1 class="govuk-heading-l">Cypress Validate Dropdown Options Example</h1>
<form method="post">
{{ govukSelect({
id: "sort",
name: "sort",
label: {
text: "Sort by"
},
items: availableOptions
}) }}
{{ govukButton({
text: "Save and continue"
}) }}
</form>
{% endblock %}
Code language: Twig (twig)
So far, so good.
The Cypress test should now pass:
You can carry on selecting items at this point.
The idea here is that available options are now filtered to remove any that are already selected:
What happens when you finally select all four?
Oh…
Yeah. Whoops.
The jury is still out on what should happen there :/
Wrapping Up
In this post we covered a few ways of using Cypress to validate the list of available drop down options.
We saw how it’s fairly easy to test an initial, static list of known options.
And that with a bit of extra code we could filtered down our list from the initially available options, and still use Cypress to check for the options we expect, and the options that should be missing.
The tests give us confidence to change the implementation without accidentally breaking the site. And we probably will have to change this implementation, as there is very clearly a bug as it currently stands.
But that’s for another time. And maybe even another developer. One who will hopefully be grateful that we spent so much time testing our current solution.