Eleven phrases our prices may never be described with, 28 tests from seven it() calls, and the one price on the site the test cannot see
PubTrivia sells in 24 currencies, and the rule behind all of it is one sentence: the price you are shown is the price you are charged. Not a conversion of a pound price, not an estimate, not a guide to what your card wil
PubTrivia sells in 24 currencies, and the rule behind all of it is one sentence: the price you are shown is the price you are charged. Not a conversion of a pound price, not an estimate, not a guide to what your card will be billed. The number on the page is the number.
That rule lives in a 24 row table of authored prices, which I have written about before. What I had not written about is the part that guards the words, because a localised price is only honest if nothing beside it hedges. A card that says €36.99 with "approx." under it has given the whole thing away.
So the assertion is a regex.
The regex is the test
export const CONVERSION_WORDING =
/approx|convert|≈|estimat|roughly|(charged|billed) in (gbp|pounds|sterling)|exact (amount|total)|exchange|\brate\b|card fees|rounded up/i
Eleven alternatives, in an 11 line file whose only other export is a helper that normalises the no-break spaces Intl puts between a symbol and a number. Each alternative is a thing somebody writes in good faith when they are nervous about a localised price:
-
approxandestimatandroughly, the hedges -
convertandexchangeand\brate\b, the mechanism leaking into the copy -
billed in pounds, the admission that the displayed figure is decorative -
exact amountandexact total, which only appear in a sentence that implies the shown one is inexact -
card feesandrounded up, the apologies
The word boundary on \brate\b is load-bearing. Without it the pattern matches "generate", which appears in perfectly innocent copy, and a test that cries wolf gets deleted within a month.
Seven it() calls, 28 tests
$ npx vitest run __tests__/pricing/price-copy.test.ts
Tests 28 passed (28)
The file contains seven literal it( calls. The rest come out of loops.
const CURRENCIES: DisplayCurrency[] = ['gbp', 'eur', 'sek', 'jpy', 'huf', 'usd']
const SURFACES = [
['the pricing section', PricingSection, {}],
['the dashboard plan cards', PricingCards, { currentPlanType: 'trial' }],
] as const
Two surfaces times six currencies is twelve tests, and each one renders the component for real:
const html = renderToStaticMarkup(
createElement(DisplayCurrencyProvider, { currency },
createElement(component, props))
)
return html.replace(/<[^>]+>/g, ' ')
Then asserts four things about the resulting text: that both plan prices appear in that currency's formatting, that the explanatory note for that currency appears, that the conversion regex does not match, and, for every currency except sterling, that the strings £, GBP, pound and sterling are absent entirely.
That last one is the assertion I would keep if I could only keep one. A stray pound sign next to a euro price is the single most expensive typo on a pricing page, because it does not look like a bug. It looks like a disclosure.
The six currencies are not arbitrary either. GBP is the authored currency. EUR is the largest market. SEK is a currency whose formatting puts the symbol after the number. JPY has no decimal places. HUF is written without decimals in local convention but is not on Stripe's zero decimal list, which is a disagreement worth a test of its own. USD covers the United States and the handful of other countries that use it, while anywhere unmapped falls back to sterling.
There is no testing library here and no DOM. The vitest environment is node, the render is renderToStaticMarkup, and the tags come off with a regex. For assertions about visible text that is enough, and the whole file runs in 669ms including transform and import, rather than booting jsdom.
Eleven files read as text
The second block does not render anything. It reads source:
const FILES = [
'app/pricing/page.tsx',
'app/faq/page.tsx',
'app/legal/terms/page.tsx',
'app/legal/privacy/page.tsx',
'app/dashboard/plans/page.tsx',
'app/dashboard/billing/page.tsx',
'components/PricingSection.tsx',
'components/PricingCards.tsx',
'components/pricing/Price.tsx',
'lib/content/registry.ts',
'lib/content/structured-data.ts',
]
for (const file of FILES) {
it(`${file} never calls our prices converted or approximate`, () => {
expect(read(file)).not.toMatch(CONVERSION_WORDING)
})
}
Eleven tests that are one line each. Reading a page as a string rather than rendering it is a deliberate trade: these are files that cannot be rendered outside Next, because they read the incoming request or pull in Supabase, and the choice is between no coverage and crude coverage. Crude coverage of a copy rule turns out to be most of the value, because the failure mode being prevented is somebody typing a word, and a word is visible in source.
The same section then quotes our own sentences back at us:
expect(page).toContain('the price you see here is the price you pay at checkout')
expect(page).toContain('Prices include VAT where it applies')
expect(page).toContain('your plan stays in the currency it started in')
and four more out of the terms of service, including the price shown is the price you pay and billed in the currency it started in. Those are assertions that a promise is still on the page. You can read all three of the first set on /pricing and the others in the terms. If somebody tidies that paragraph away, the suite goes red, which is the only mechanism I have ever found that reliably stops a legal commitment from being edited into a nicer rhythm.
The page the test cannot see
Eleven files. The site has 72 URLs in its sitemap:
$ curl -s https://pub-trivia.app/sitemap.xml | grep -c '<loc>'
72
15 guides, 15 question category pages, 12 venue pages, 10 feature pages, 8 comparison pages, 5 tools, 3 legal documents, and the four singles. None of the guides, venue pages or comparison pages are in that FILES list, and there is exactly one of them that writes our price in words.
Open these two in the same browser, from anywhere in the eurozone:
- /pricing shows €36.99 and €121.99
- /compare/kahoot-for-pub-quiz has a comparison row headed "Commercial pricing" that says £30 or £100 a month, per venue, cancellable monthly
Here is the confirmation from a European IP, no account needed:
$ curl -s https://pub-trivia.app/pricing | grep -oE '(€|£)[0-9.]+'
€36.99
€121.99
$ curl -s https://pub-trivia.app/compare/kahoot-for-pub-quiz | grep -oE '£[0-9]+' | sort -u
£100
£30
Nothing there is dishonest. £30 and £100 are the authored prices, the row is accurate, and the cell next to it quotes the competitor's published tiers in dollars because dollars is how they publish them. But a visitor who has just read €36.99 on the pricing page and then reads £30 on the comparison page has been handed exactly the thought the whole 24 row table exists to prevent: so which one is the real price?
Why that page cannot just fix itself
Because of what it is.
$ curl -sI https://pub-trivia.app/compare/kahoot-for-pub-quiz | grep -iE 'x-nextjs-prerender|^age'
x-nextjs-prerender: 1
age: 345050
It is prerendered and it has been sitting on the CDN for four days. 70 of the 72 sitemap URLs are like that. The only two that are not are the homepage and /pricing, and the reason those two are per-request is precisely this feature: they read the visitor's country from a request header to pick a currency, which a page built at deploy time cannot do.
$ curl -sI https://pub-trivia.app/pricing | grep -i cache-control
cache-control: private, no-cache, no-store, max-age=0, must-revalidate
So localising that one table row means making a comparison page per-request: no CDN, a function invocation for every crawler and every reader, on a page whose entire job is to rank in search results and be cheap. For one row. That is a bad trade, which is why the row still says pounds.
What I am actually doing about it
Three options, in descending order of how much I like them.
Drop the figure and link. The row becomes "Two plans, monthly, cancellable, per venue" and links to /pricing, which is the one page in the product that knows what currency you are in. The comparison stays accurate forever and stops being a second place where a price lives.
Keep pounds and say so. "From £30 a month in the UK; priced locally in 24 currencies." Honest, static, cacheable, and slightly clumsy, which is the usual price of honest and static.
Extend the test to all 72. Grep every page in the sitemap's source for a currency symbol and fail unless the file imports the pricing module. That catches the next one, and it is three lines in a file that already reads source as text. It is also the only one of the three that makes the problem not recur, so it is happening regardless of which of the other two I pick.
The thing I would not do is add the compare page to the FILES list and call it covered. The regex in that list checks for hedging words, and this page has no hedging words. It has a correct number in the wrong currency, which no regex of adjectives was ever going to find.
Twenty-eight tests, one of which is eleven tests pretending to be one, guarding a rule that is one sentence long. The rule still held everywhere the test looked. It is where the test does not look that a price gets a second opinion.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.