Writing Tests for a Chrome Extension: My No-Framework Approach

Testing browser extensions is awkward. The code runs in multiple contexts (content script, popup, background), interacts with the DOM, and depends on Chrome APIs that don't exist in Node.js.
Most guides recommend Jest, Puppeteer, or elaborate mocking setups. I tried a different approach: structure the code so most logic is testable without any of that.
Here's how I test HTML Table Exporter with 60+ tests, zero test framework dependencies, and a runner that completes in 200ms.
The Core Insight: Separate Pure Logic from Browser APIs
The key architectural decision was separating code into two categories:
1. Pure functions (testable in Node.js)
Table matrix extraction
Data cleaning/transformation
Export format conversion
Business logic (review timing, license validation rules)
2. Browser-dependent code (not unit tested)
DOM selection (querySelector, table.rows)
Chrome storage API
Download triggering
UI rendering
The pure functions contain 80% of the complexity. The browser code is mostly glue.
The Test Structure
Each module has a companion test file:
packages/shared/core/
├── tableExtraction.js
├── tableExtraction_test.js
├── cleaningPresets.js
├── cleaningPresets_test.js
├── exporters.js
├── exporters_test.js
├── reviewPrompt.js
├── reviewPrompt_test.js
└── runAllTests.js
Tests are plain JavaScript functions that return pass/fail counts:
// tableExtraction_test.js
export function runTableExtractionTests() {
let passed = 0;
let failed = 0;
// Test: Basic 2x2 table
{
const html = `
<table>
<tr><td>A</td><td>B</td></tr>
<tr><td>C</td><td>D</td></tr>
</table>
`;
const result = extractTableMatrix(parseHTML(html));
if (
result.length === 2 &&
result[0][0] === "A" &&
result[1][1] === "D"
) {
passed++;
} else {
failed++;
console.log("FAIL: Basic 2x2 table");
console.log(" Expected: [['A','B'],['C','D']]");
console.log(" Got:", result);
}
}
// More tests...
return { passed, failed, total: passed + failed };
}
No describe, no it, no assertions library. Just conditionals.
Running Tests in Node.js
The pure functions don't use browser APIs, so they run directly in Node:
// runAllTests.js
import { runCleaningTests } from "./cleaningPresets_test.js";
import { runTableExtractionTests } from "./tableExtraction_test.js";
import { runExportersTests } from "./exporters_test.js";
import { runReviewPromptTests } from "./reviewPrompt_test.js";
async function runAllTests() {
const suites = [
{ name: "Cleaning Presets", runner: runCleaningTests },
{ name: "Table Extraction", runner: runTableExtractionTests },
{ name: "Exporters", runner: runExportersTests },
{ name: "Review Prompt", runner: runReviewPromptTests },
];
let totalPassed = 0;
let totalFailed = 0;
for (const suite of suites) {
const results = await suite.runner();
totalPassed += results.passed;
totalFailed += results.failed;
const status = results.failed === 0 ? "✓" : "✗";
console.log(`\({status} \){suite.name}: \({results.passed}/\){results.total}`);
}
console.log(`\nTotal: \({totalPassed}/\){totalPassed + totalFailed}`);
process.exit(totalFailed > 0 ? 1 : 0);
}
runAllTests();
Running node runAllTests.js:
════════════════════════════════════════════════════════
HTML TABLE EXPORTER - FULL TEST SUITE
════════════════════════════════════════════════════════
✓ Cleaning Presets: 18/18
✓ Table Extraction: 24/24
✓ Exporters: 12/12
✓ Review Prompt: 8/8
Total: 62/62
Success rate: 100%
ALL TESTS PASSED!
200ms. No npm install. No configuration.
Testing DOM-Adjacent Code
For table extraction, I need to parse HTML. Rather than mocking the DOM, I use a minimal HTML parser that runs in Node:
// At the top of tableExtraction_test.js
import { JSDOM } from "jsdom";
function parseHTML(html) {
const dom = new JSDOM(html);
return dom.window.document.querySelector("table");
}
JSDOM is the one external dependency in tests—and it's dev-only. The production code uses the real DOM.
What the Tests Actually Cover
Table Extraction Tests (24 tests)
The table parser handles complex cases:
// Rowspan test
{
const html = `
<table>
<tr><td rowspan="2">A</td><td>B</td></tr>
<tr><td>C</td></tr>
</table>
`;
const result = extractTableMatrix(parseHTML(html));
// Expected: [["A", "B"], ["A", "C"]]
// The rowspan cell "A" should appear in both rows
}
// Colspan test
{
const html = `
<table>
<tr><td colspan="2">Header</td></tr>
<tr><td>A</td><td>B</td></tr>
</table>
`;
const result = extractTableMatrix(parseHTML(html));
// Expected: [["Header", "Header"], ["A", "B"]]
}
// Wikipedia navigation row test
{
const html = `
<table>
<tr><td>v t e Some category</td></tr>
<tr><td>Name</td><td>Value</td></tr>
<tr><td>Item 1</td><td>100</td></tr>
</table>
`;
// Should detect the "v t e" row and skip it
}
Cleaning Preset Tests (18 tests)
Number normalization across locales:
// European format
assertEqual(normalizeNumber("1.234,56"), 1234.56);
// US format
assertEqual(normalizeNumber("1,234.56"), 1234.56);
// With currency symbol
assertEqual(normalizeNumber("€1.234,56"), 1234.56);
// Percentage
assertEqual(normalizeNumber("45,5%"), 0.455);
Exporter Tests (12 tests)
CSV edge cases:
// Comma in value
assertEqual(
toCSV([["Hello, World", "Test"]]),
'"Hello, World",Test'
);
// Quote in value
assertEqual(
toCSV([['Say "Hello"', "Test"]]),
'"Say ""Hello""",Test'
);
// Newline in value
assertEqual(
toCSV([["Line 1\nLine 2", "Test"]]),
'"Line 1\nLine 2",Test'
);
Review Prompt Tests (8 tests)
Timing logic for when to show the review prompt:
// Should not prompt before 14 days (free user)
{
const state = {
firstExportTimestamp: Date.now() - (10 * 24 * 60 * 60 * 1000), // 10 days ago
totalExports: 20,
};
assertEqual(shouldPromptForReview(state, "free"), false);
}
// Should prompt after 14 days + 15 exports
{
const state = {
firstExportTimestamp: Date.now() - (15 * 24 * 60 * 60 * 1000), // 15 days ago
totalExports: 20,
};
assertEqual(shouldPromptForReview(state, "free"), true);
}
Why Not Jest?
I actually started with Jest. Then I removed it. Here's why:
1. Setup overhead. Jest needs configuration, transforms, module resolution fixes. For 60 tests, that's not worth it.
2. Slow startup. Jest takes 2-3 seconds just to start. My tests run in 200ms total.
3. Dependency weight. node_modules bloat for what's essentially if (expected !== actual) fail().
4. False confidence. A green Jest output doesn't mean more than my simple pass/fail. Tests either pass or they don't.
For a large team or complex project, Jest's features matter. For a solo project with well-structured code, plain functions work fine.
The Tradeoff: What's NOT Tested
I don't unit test:
UI rendering (buttons appearing, CSS classes)
Chrome API interactions (storage, tabs, downloads)
End-to-end flows (popup → content script → export)
These are tested manually before each release. It's not ideal, but the pure logic tests catch 95% of bugs.
If the project grows, I'd add Puppeteer for E2E tests. For now, the ROI isn't there.
Key Takeaways
Structure code for testability. Pure functions > tangled browser code.
You don't need a test framework. Functions returning pass/fail work.
Test the complex parts. Table parsing edge cases matter more than button clicks.
Keep it fast. If tests are slow, you won't run them.
The best test suite is one you actually run before every commit. Mine runs in 200ms, so I run it constantly.
HTML Table Exporter is available at gauchogrid.com. Try it on the Chrome Web Store. All 62 tests passing, last I checked.




