Check Fees And Totals Using Cypress

How to confirm the dollar amounts and sums in your Cypress tests.

Imagine you have a small page showing item prices and fees. For example, it could be a checkout page. The fees must add up to the total.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<dl data-cy="price">
<dt>Price</dt>
<dd>$10.99</dd>
</dl>

<dl data-cy="shipping">
<dt>Shipping (2-day)</dt>
<dd>$6.99</dd>
</dl>

<dl data-cy="handling">
<dt>Handling fee</dt>
<dd>$1.39</dd>
</dl>

<dl data-cy="tips">
<dt>Tips</dt>
<dd>$1.99</dd>
</dl>

<hr />
<dl data-cy="total">
<dt>Total</dt>
<dd>$21.36</dd>
</dl>

The example page

Can we verify the individual fees and confirm the total is correct?

🎁 You can find the source code for this blog post in the repo bahmutov/cypress-prices-check.

Hardcoded values

If you know the precise values, the test becomes easy.

cypress/e2e/fees.cy.js
1
2
3
4
5
6
7
8
9
10
11
beforeEach(() => {
cy.visit('index.html')
})

it('shows the expected fees', () => {
cy.contains('[data-cy=price] dd', '$10.99')
cy.contains('[data-cy=shipping] dd', '$6.99')
cy.contains('[data-cy=handling] dd', '$1.39')
cy.contains('[data-cy=tips] dd', '$1.99')
cy.contains('[data-cy=total] dd', '$21.36')
})

The test checks the hard-coded values

We can refactor the test a little to avoid duplicated selectors

1
2
3
4
5
6
7
8
9
10
11
const fee = (name, value) => {
cy.contains(`[data-cy=${name}] dd`, value)
}

it('shows the expected fees (simple selectors)', () => {
fee('price', '$10.99')
fee('shipping', '$6.99')
fee('handling', '$1.39')
fee('tips', '$1.99')
fee('total', '$21.36')
})

Utility function to check each fee

Tip: I usually refactor my tests with the help of Copilot, see my course Write Cypress Tests Using GitHub Copilot

GitHub Copilot code suggestion based on the test

One array

Instead of checking each fee element, we can collect all fees and totals into a single array for checking. For simplicity and elegance, I will use custom queries from my cypress-map plugin.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// https://github.com/bahmutov/cypress-map
import 'cypress-map'

beforeEach(() => {
cy.visit('index.html')
})

it('shows the expected fees (one array)', () => {
const names = ['price', 'shipping', 'handling', 'tips', 'total']
const selectors = names.map((name) => `[data-cy=${name}] dd`)
// cy.getInOrder and cy.map are custom commands
// from the cypress-map plugin
cy.getInOrder(...selectors)
.map('innerText')
.should('deep.equal', ['$10.99', '$6.99', '$1.39', '$1.99', '$21.36'])
})

All fees in one array

Long object and arrays are shortened by the Chai assertions, but we can increase the truncation threshold.

1
2
3
4
5
6
7
chai.config.truncateThreshold = 300

it('shows the expected fees (one array)', () => {
const names = ['price', 'shipping', 'handling', 'tips', 'total']
const selectors = names.map((name) => `[data-cy=${name}] dd`)
...
})

Showing full arrays

Still, the array might be pretty long and have just a single item difference, yet we print it entirely.

One object

Let's give each fee a name before comparing.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
it.only('shows the expected fees (one object)', () => {
const names = ['price', 'shipping', 'handling', 'tips', 'total']
const selectors = names.map((name) => `[data-cy=${name}] dd`)
cy.getInOrder(...selectors)
.map('innerText')
.apply((values) => Cypress._.zipObject(names, values))
.should('deep.equal', {
price: '$10.99',
shipping: '$6.99',
handling: '$1.39',
tips: '$1.99',
total: '$21.36',
})
})

Again, cy.getInOrder, cy.map, and cy.apply are queries from cypress-map.

Showing full objects

Let's see how it looks when some of the properties are incorrect.

The failing test when some of the object properties are different

Which values in the large object are different?

cy-spok

We can use plugin cy-spok to better report the first different property.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// https://github.com/bahmutov/cy-spok
import spok from 'cy-spok'

it('has wrong fees (cy-spok)', () => {
const names = ['price', 'shipping', 'handling', 'tips', 'total']
const selectors = names.map((name) => `[data-cy=${name}] dd`)
cy.getInOrder(...selectors)
.map('innerText')
.apply((values) => Cypress._.zipObject(names, values))
.should(
spok({
price: '$10.99',
shipping: '$6.99',
handling: '$1.39',
tips: '$0.99',
total: '$25.36',
}),
)
})

Correct and the first wrong property as shown by cy-spok

Unfortunately, cy-spok stops when it sees the first property with the different value. I would like to see all different properties.

Custom difference

We can compute the difference between the current subject and the expected object of values ourselves. Let's add a query command so it retries. If there are no differences, cy.difference yields an empty object.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Cypress.Commands.addQuery('difference', (expected) => {
const names = Object.keys(expected)
return (subject) => {
const diff = {}
names.forEach((name) => {
const actual = subject[name]
const expectedValue = expected[name]
if (actual !== expectedValue) {
diff[name] = { actual, expected: expectedValue }
}
})
return diff
}
})

it('has wrong fees (custom difference)', () => {
const names = ['price', 'shipping', 'handling', 'tips', 'total']
const selectors = names.map((name) => `[data-cy=${name}] dd`)
cy.getInOrder(...selectors)
.map('innerText')
.apply((values) => Cypress._.zipObject(names, values))
.difference({
price: '$10.99',
shipping: '$6.99',
handling: '$1.39',
tips: '$0.99',
total: '$25.36',
})
.should('be.empty')
})

The error is now simply reports the properties with the different values.

Custom difference query

Tip: you can use point-free programming by partially applying the first argument to the Cypress._.zipObject method:

1
2
3
4
cy.getInOrder(...selectors)
.map('innerText')
// partially apply the zip callback
.apply(Cypress._.zipObject.bind(null, names))

Tip 2: there is cy.difference query in the cypress-map plugin, with the same logic.

📺 Watch the same test refactored from normal to cy.difference chain in the video Check Multiple Properties At Once Using cy.difference Query.

Tip 3: the command cy.difference in cypress-map even allows you to use predicates to check each property

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const tid = (id) => `[data-cy=${id}] dd`

it('shows the fees and the total', () => {
const names = ['price', 'shipping', 'handling', 'tips', 'total']
const shippingPriceCheck = (amount) => amount > 5 && amount < 10
cy.getInOrder(names.map(tid))
.map('innerText')
.mapInvoke('replace', '$', '')
.mapInvoke('replace', ',', '')
.map(Number)
.print()
.apply((values) => Cypress._.zipObject(names, values))
.difference({
price: 10.99,
shipping: shippingPriceCheck,
handling: Cypress._.isNumber,
tips: (amount) => Cypress._.inRange(amount, 0.1, 2),
total: 21.36,
})
.should('be.empty')
})

You can watch the above test explained in my video cy.difference Command With Predicates.

Confirm the sum

Now let's move away from confirming individual fees to computing the total. The final total price on the page should equal to the sum of all fees. We must also consider negative fees and formatted currency.

A checkout page with formatted currency and negative fees

Ok, let's convert each string to a number and confirm the sum is equal to the last number

cypress/e2e/total.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// https://github.com/bahmutov/cypress-map
import 'cypress-map'

beforeEach(() => {
cy.visit('total.html')
})

it('adds up all fees to equal the total', () => {
const names = ['price', 'shipping', 'handling', 'coupon', 'total']
const selectors = names.map((name) => `[data-cy=${name}] dd`)
cy.getInOrder(...selectors)
.map('innerText')
.mapInvoke('replace', '$', '')
.mapInvoke('replace', ',', '')
.map(Number)
.print()
.should((numbers) => {
const [price, shipping, handling, coupon, total] = numbers
expect(price + shipping + handling + coupon).to.equal(total)
})
})

The test confirming the fees add up

The test retries

All commands in the above test are queries, thus they retry. Even if some fees load slowly, the test should pass. Imagine, the coupon's value is fetched from a remote service, thus there is a delay.

1
2
3
4
5
6
7
8
9
10
11
<dl data-cy="total">
<dt>Total</dt>
<dd>$1,072.97</dd>
</dl>
<script>
setTimeout(() => {
// load the coupon after a delay
const coupon = document.querySelector('[data-cy=coupon] dd')
coupon.innerText = '-$75.00'
}, 1000)
</script>

No problem, the test will retry. The invalid number is a null, which causes the sum to be NaN.

Just a precaution I like checking the total to be within some certain numerical range. This should avoid accidentally confirming 0 + 0 + ... + 0 = 0.

1
2
3
4
5
6
.should((numbers) => {
const [price, shipping, handling, coupon, total] = numbers
expect(price + shipping + handling + coupon)
.to.be.greaterThan(100)
.and.to.equal(total)
})

Close enough

We are using floats to compute the dollar sum. Floating-point numbers suffer from precision loss. Let's imagine we have two numbers that end in 4 and 3. Their sum will have a floating-point error

1
2
3
4
5
6
7
8
9
<dl data-cy="price">
<dt>Price</dt>
<dd>$1,099.93</dd>
</dl>

<dl data-cy="shipping">
<dt>Shipping (2-day)</dt>
<dd>$16.94</dd>
</dl>

I got 99 problems, or is it 99.00000001?

We can deal with this problem in 3 different ways.

  1. Use the closeTo assertion
1
2
3
4
5
6
.should((numbers) => {
const [price, shipping, handling, coupon, total] = numbers
expect(price + shipping + handling + coupon)
.to.be.greaterThan(100)
.and.be.closeTo(total, 0.001)
})

Comparing floating-point numbers using closeTo assertion

  1. Perform arithmetic using whole numbers

Instead of dollars, we can use cents to compute the sum and do the comparison.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
it('adds up all fees using cents', () => {
const names = ['price', 'shipping', 'handling', 'coupon', 'total']
const selectors = names.map((name) => `[data-cy=${name}] dd`)
cy.getInOrder(...selectors)
.map('innerText')
.mapInvoke('replace', '$', '')
.mapInvoke('replace', ',', '')
.map(Number)
.map((n) => n * 100)
.map(Math.round)
.print()
.should((numbers) => {
const [price, shipping, handling, coupon, total] = numbers
expect(price + shipping + handling + coupon)
.to.be.greaterThan(10_000)
.and.to.equal(total)
})
})

Comparing currency using whole numbers of cents

  1. Use a dedicated library for dealing with currency operations

In this blog post I will use Dinero.js v2. Let's install the necessary dev dependencies

1
2
3
$ npm i -D dinero.js@alpha @dinero.js/currencies@alpha
+ dinero.js@alpha 2.0.0-alpha.14
+ @dinero.js/currencies@alpha 2.0.0-alpha.14

Here is out test that uses a few utility functions from Dinero library

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// https://github.com/bahmutov/cypress-map
import 'cypress-map'
// https://v2.dinerojs.com/docs
import { dinero, add, equal, toDecimal } from 'dinero.js'
import { USD } from '@dinero.js/currencies'

function dineroFromFloat(amountString) {
const cleaned = amountString.replace('$', '').replace(',', '')
const amount = Math.round(Number(cleaned * 100))

return dinero({ amount, currency: USD })
}

it('adds up all fees using Dinero.js', () => {
const names = ['price', 'shipping', 'handling', 'coupon', 'total']
const selectors = names.map((name) => `[data-cy=${name}] dd`)
cy.getInOrder(...selectors)
.map('innerText')
.print()
.map(dineroFromFloat)
.should((numbers) => {
const [price, shipping, handling, coupon, total] = numbers
const sum = [price, shipping, handling, coupon].reduce(add)
expect(equal(sum, total), `${toDecimal(sum)} = ${toDecimal(total)}`).to.be
.true
})
})

Calculating currency using Dinero.js

Nice.