How to test cookie consent banner

Load the page in a clean profile, read the cookie jar with CDP Network.getAllCookies, click Accept all, read it again. Repeat in a second clean profile with Reject all, then diff the two jars name by name. On the fixture below, five of six cookies were identical after both clicks.

Why check this

Run this on any release that touches the banner vendor, the tag manager, or the map from a consent category to the scripts it gates. It belongs in regression, because the banner keeps rendering and the button keeps responding long after the branch behind it stopped working.

The failure it catches is the commonest one in this area. A visitor clicks Reject all, the banner hides, the page looks settled, and the analytics cookies are written anyway because only the banner's own state cookie is wired to the decision. No screenshot shows it. No console message reports it. The only evidence is two cookie jars read after two different clicks and compared name by name.

Reading the jar through the DevTools Protocol rather than through document.cookie matters here, because a server-set HttpOnly tracking cookie is invisible to script. A test built on document.cookie reports a shorter list than the browser is holding and can pass while the defect is present.

Prerequisites

// consent-fixture.mjs   node consent-fixture.mjs   ->   http://localhost:9601/
import { createServer } from 'node:http';

const YEAR = 'Max-Age=31536000; Path=/';
const records = new Map(); // sid -> consent record
const html = `<!doctype html><meta charset="utf-8"><title>Fixture shop</title>
<h1>Fixture shop</h1><p id="state">consent: not recorded</p>
<div id="banner"><p>We use cookies for analytics and marketing.</p>
<button id="accept">Accept all</button> <button id="reject">Reject all</button></div>
<button id="withdraw">Withdraw consent</button>
<script>
const send = (d) => fetch('/consent', { method: 'POST', headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ decision: d }) }).then((r) => r.json()).then((j) => {
    state.textContent = 'consent: ' + j.decision;
    banner.hidden = j.decision !== 'withdraw';
    localStorage.setItem('fx_consent', JSON.stringify({ decision: j.decision, at: j.at }));
    if (j.decision === 'accept') localStorage.setItem('fx_analytics_queue', '["pageview:/"]');
    sessionStorage.setItem('fx_banner_seen', '1');
  });
accept.onclick = () => send('accept');
reject.onclick = () => send('reject');
withdraw.onclick = () => send('withdraw');
</script>`;

createServer((req, res) => {
  const sid = /fx_sid=([^;]+)/.exec(req.headers.cookie || '')?.[1] || 'sid-7f3a';
  if (req.url === '/echo') {
    res.writeHead(200, { 'content-type': 'application/json' });
    return res.end(JSON.stringify({ cookieHeader: req.headers.cookie || null }));
  }
  if (req.url === '/consent/record') {
    res.writeHead(200, { 'content-type': 'application/json' });
    return res.end(JSON.stringify(records.get(sid) || null, null, 2));
  }
  if (req.url === '/consent' && req.method === 'POST') {
    let body = '';
    req.on('data', (c) => (body += c));
    return req.on('end', () => {
      const decision = JSON.parse(body).decision;
      const at = new Date().toISOString();
      const prev = records.get(sid);
      records.set(sid, decision === 'withdraw'
        ? { ...prev, decision: 'withdraw', withdrawnAt: at }
        : { id: 'cr-' + Math.random().toString(36).slice(2, 10), subject: sid, decision, at,
            categories: { necessary: true, analytics: decision === 'accept', marketing: decision === 'accept' },
            policyVersion: '2026-03-01', bannerVersion: '1.4.0', scope: 'http://localhost:9601',
            method: 'banner-button' });
      // The defect under test: both decisions set the same analytics cookies.
      const jar = decision === 'withdraw' ? [] : [`fx_ga=GA1.2.884.1789; ${YEAR}`, `fx_fbp=fb.1.771; ${YEAR}`,
        `fx_uid=u-91af; HttpOnly; ${YEAR}`];
      res.writeHead(200, { 'content-type': 'application/json',
        'set-cookie': [...jar, `fx_consent=${decision}; ${YEAR}`] });
      res.end(JSON.stringify({ decision, at }));
    });
  }
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8',
    'set-cookie': [`fx_sid=${sid}; HttpOnly; Path=/`, `fx_seen=1; ${YEAR}`] });
  res.end(html + (req.url.includes('tp=1') ? '<img src="http://127.0.0.1:9602/px.gif" alt="">' : ''));
}).listen(9601, () => console.log('fixture on http://localhost:9601/'));
// banner-run.mjs   node banner-run.mjs accept|reject
import { launch } from 'puppeteer-core';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const decision = process.argv[2];
const profile = mkdtempSync(join(tmpdir(), 'consent-')); // a clean jar every run
const browser = await launch({ executablePath: CHROME, headless: true, userDataDir: profile });
try {
  const page = (await browser.pages())[0];
  const cdp = await page.createCDPSession();
  await cdp.send('Network.enable');
  const jar = async () => (await cdp.send('Network.getAllCookies')).cookies
    .filter((c) => c.domain === 'localhost').map((c) => `${c.name}=${c.value}`).sort();

  await page.goto('http://localhost:9601/', { waitUntil: 'networkidle2' });
  console.log('before any click :', (await jar()).join(' '));
  console.log('banner visible   :', await page.$eval('#banner', (e) => !e.hidden));

  await page.click('#' + decision);
  await page.waitForFunction("state.textContent !== 'consent: not recorded'");
  const after = await jar();
  console.log(`after "${decision}"    :`, after.join(' '));
  console.log('banner visible   :', await page.$eval('#banner', (e) => !e.hidden));
  writeFileSync(`jar-${decision}.json`, JSON.stringify(after, null, 1));
} finally {
  await browser.close();
  rmSync(profile, { recursive: true, force: true });
}
// jar-diff.mjs   node jar-diff.mjs
import { readFileSync } from 'node:fs';
const pairs = (f) => new Map(readFileSync(f, 'utf8').match(/"([^"]+)"/g)
  .map((s) => s.slice(1, -1).split(/=(.*)/)).map(([n, v]) => [n, v]));
const a = pairs('jar-accept.json');
const r = pairs('jar-reject.json');
for (const name of new Set([...a.keys(), ...r.keys()]).values()) {
  const av = a.get(name), rv = r.get(name);
  console.log(`${name.padEnd(11)} accept=${String(av).padEnd(16)} reject=${String(rv).padEnd(16)} ${av === rv ? 'SAME' : 'differs'}`);
}

Steps

  1. Step 1.

    Start the fixture and leave it running.

    node consent-fixture.mjs
    
    fixture on http://localhost:9601/

    Port 9601 is the target for every step below.

  2. Step 2.

    Run the accept path in a clean profile.

    node banner-run.mjs accept
    
    before any click : fx_seen=1 fx_sid=sid-7f3a
    banner visible   : true
    after "accept"    : fx_consent=accept fx_fbp=fb.1.771 fx_ga=GA1.2.884.1789 fx_seen=1 fx_sid=sid-7f3a fx_uid=u-91af
    banner visible   : false

    Two cookies existed before the click and six after it. fx_uid is HttpOnly, so it is in this list only because the jar was read over CDP.

  3. Step 3.

    Run the reject path in a second clean profile.

    node banner-run.mjs reject
    
    before any click : fx_seen=1 fx_sid=sid-7f3a
    banner visible   : true
    after "reject"    : fx_consent=reject fx_fbp=fb.1.771 fx_ga=GA1.2.884.1789 fx_seen=1 fx_sid=sid-7f3a fx_uid=u-91af
    banner visible   : false

    The banner hid itself and the state cookie says reject. Read the rest of the line before calling the test passed.

  4. Step 4.

    Compare the two jars name by name.

    node jar-diff.mjs
    
    fx_consent  accept=accept           reject=reject           differs
    fx_fbp      accept=fb.1.771         reject=fb.1.771         SAME
    fx_ga       accept=GA1.2.884.1789   reject=GA1.2.884.1789   SAME
    fx_seen     accept=1                reject=1                SAME
    fx_sid      accept=sid-7f3a         reject=sid-7f3a         SAME
    fx_uid      accept=u-91af           reject=u-91af           SAME

    One row differs, and it is the banner's own state cookie. The analytics pair and the server-set identifier are byte for byte the same after a rejection as after an acceptance. That is the defect, and it is the whole finding.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | Only the state cookie differs | The decision reaches the banner and nothing else | Raise it as a functional defect, not a wording one. The consent value is stored and ignored. | | The reject jar holds fewer names | The gate works for the cookies that are missing | Name the remaining ones and check each against the category map. | | A cookie appears before any click | Something writes before a decision exists | Go to How to check if cookies are set without consent. | | The jar is longer than document.cookie | Server-set HttpOnly cookies are present | Keep reading the jar over CDP. A script-based test under-reports here. | | Both jars are identical, state cookie included | The click never reached the server | Confirm the POST in the network log before blaming the cookie logic. |

Common mistakes

Sign: The test asserts on the banner disappearing and passes.Cause: Hiding the banner is a DOM change that runs in the success callback. It proves the request resolved, not that the decision was applied. In the capture above the banner hid on both paths and the cookie sets were identical.
Sign: The test reads document.cookie and sees fewer cookies than DevTools shows.Cause: document.cookie omits every HttpOnly cookie by design. The fixture sets fx_uid with HttpOnly, so a script-based inventory returns four names where the jar holds six, and one of the two it misses is the tracking identifier.
Sign: Accept and reject are run in the same browser profile and the jars look alike.Cause: Cookies written by the first run survive into the second. Every run needs its own profile directory, or the result reports the history of the test rather than the behaviour of the banner.
Sign: Only cookie names are compared, and a value change is missed.Cause: A vendor that rewrites an identifier on reject still tracks, and a name-only diff reports SAME. Compare name and value, as the diff script above does.

What to check next

FAQ

How do I check if cookie consent is working?

Compare cookie jars, not screens. Read the jar in a clean profile after Accept all and after Reject all, then diff them. If the only difference is the banner's own state cookie, the decision is stored and not applied.

Why are cookies still tracking after reject all?

Usually one branch. The consent callback writes the state cookie and hides the banner on both paths, while the code that sets the analytics cookies runs unconditionally. The fixture on this page reproduces that shape in nine lines.

Can I test a cookie banner without a browser?

Not fully. Reading Set-Cookie with curl shows what arrives on the first response, but the banner decision travels through a click and a script. The click needs a real browser, and the jar read needs the DevTools Protocol.

Does the test need a new profile every time?

Yes. A cookie from a previous run persists in the profile and lands in the next jar, which makes the accept and reject results converge for a reason that has nothing to do with the banner.

What about cookies a script sets, rather than the server?

The jar holds both. Network.getAllCookies returns the profile's cookie store, so a cookie written by document.cookie in page script and a cookie from a Set-Cookie header appear in the same list.

Verified

Verified by Maks Vernynode 22.23.2Chrome 152.0.7977.76puppeteer-core 25.10.0

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate12 minpublished updated Maks Verny