Code Review Videos > JavaScript & TypeScript > [Cypress] Validate Dropdown Options Example

[Cypress] Validate Dropdown Options Example

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 gov uk select box to validate with cypress

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:
    1. 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.
    2. resave: It is set to false, 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.
    3. saveUninitialized: It is set to true, 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.

simpsons garbage in a garbage can people

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.

cypress validate dropdown options test example result

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:

cypress jquery wrapped object

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:

  1. Check the text matches
  2. 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:

switch dropdown to checkboxes example

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:

  1. User initially has no selection
  2. User visits /sort-by and makes a selection
  3. User clicks ‘Save and continue’
  4. User is taken to /check-your-answers which shows their current selection
  5. User can click ‘Add’ which takes them to /sort-by
  6. User can add another selection
  7. User clicks ‘Save and continue’
  8. 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:

gov uk check your answers page with selections

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:

cypress test select one dropdown element

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:

cypress passing tests with multiple dropdown selections

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:

Variant #2 – Extract To Shared Functions

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:

cypress drop down test expected 3 got 4

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-jestCode 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:

  1. 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 install jest as a development dependency (-D or --save-dev), it makes Jest available for running tests in your project.
  2. @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.
  3. 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:initCode 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:

cypress test passing with filtered drop down

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:

drop down with filtered values

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.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.