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 >
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' ) })
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' ) })
Tip: I usually refactor my tests with the help of Copilot, see my course Write Cypress Tests Using GitHub Copilot
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 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 (...selectors) .map ('innerText' ) .should ('deep.equal' , ['$10.99' , '$6.99' , '$1.39' , '$1.99' , '$21.36' ]) })
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` ) ... })
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 .
Let's see how it looks when some of the properties are incorrect.
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 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' , }), ) })
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.
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' ) .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.
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 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 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 (() => { 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 >
We can deal with this problem in 3 different ways.
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 ) })
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) }) })
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 import 'cypress-map' 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 }) })
Nice.