{"id":2184,"date":"2026-01-05T19:47:36","date_gmt":"2026-01-06T00:47:36","guid":{"rendered":"https:\/\/www.phpied.com\/?p=2184"},"modified":"2026-01-05T16:47:50","modified_gmt":"2026-01-05T21:47:50","slug":"do-it-yourself-code-coverage-and-testing","status":"publish","type":"post","link":"https:\/\/www.phpied.com\/do-it-yourself-code-coverage-and-testing\/","title":{"rendered":"Do It Yourself: code coverage and testing"},"content":{"rendered":"<p><script src=\"\/run_prettify.js\" defer=\"\"><\/script><\/p>\n<p>This is what turns out to be a part 3 of a series about building <a href=\"https:\/\/sightread.org\">sightread.org<\/a> with minimal tooling:<\/p>\n<ul>\n<li>Part 1: <a href=\"https:\/\/www.phpied.com\/import-javascript-like-its-2026\/\">Import JavaScript like it's 2026<\/a><\/li>\n<li>Part 2: <a href=\"https:\/\/www.phpied.com\/maximally-minimal-build-process\/\">Maximally Minimal Build Process<\/a><\/li>\n<\/ul>\n<p>The previous posts covered how I structure js modules for parallel loading and how to build a production-ready app with a 200-line build script. But I forgot to talk about testing and code coverage, two things we rarely concern ourselves with in personal projects, but are nice to have. Also this project is a bit more than a quick hack and I've bigger plans for it. In fact futzing around with software good practices is my way of procrastinating on executing the bigger plans. But hey, any large feature or refactoring is the time you pat yourself on the back for having tests. And coverage of said tests over a biggish part of the code.<\/p>\n<p>The JavaScript ecosystem offers the likes of Jest, Jasmine and a dozen other test frameworks. Each comes with its own configuration files, plugins, <code>node_modules<\/code>... But how about a DIY?<\/p>\n<p><img decoding=\"async\" src=\"https:\/\/www.phpied.com\/files\/blogimages\/sightread-testing.png\" alt=\"screenshot of running sightread.org browser tests\"><\/p>\n<h2 id=\"the-philosophy\">The Philosophy<\/h2>\n<p>The testing philosophy follows the same principle as the rest of the project: let's use what the platform gives us. E.g. both browsers and Node have <code>console.assert()<\/code>. And what's the big deal about an assert, really? I don't need no stinking framework for an assert.<\/p>\n<p>I ended up with:<\/p>\n<ul>\n<li>Zero test framework dependencies<\/li>\n<li>Two test \"suites\": one for Node.js to test JS-land, one for the browser to test DOM-land and browsery things, like URLs<\/li>\n<li>Using two coverage tools: c8 for Node.js and Puppeteer's built-in coverage for the browser<\/li>\n<li>and (after I used the new coverage tools to increase my coverage) now I'm enforcing coverage at build time, or otherwise fail the build<\/li>\n<\/ul>\n<h2 id=\"node-js-tests-for-pure-logic\">Node.js tests for pure logic<\/h2>\n<p>The Node tests live in <code>test\/test-node.js<\/code> and test the pure functions that don't need a DOM: rhythm generation, pattern validation, URL parsing, ABC notation translation.<\/p>\n<p>Here's the entire test framework:<\/p>\n<pre class=\"prettyprint\">function assert(condition, message) {\n  console.assert(condition, message);\n}\n<\/pre>\n<p>Yup, <code>console.assert()<\/code> throws in Node.js when a condition fails and that's all I need. The test file imports the modules under test and exercises them:<\/p>\n<pre class=\"prettyprint\">import { generateRhythm, consolidateRests } from '..\/src\/music-lib.js';\nimport { parseSettingsFromHash, compressSettings } from '..\/src\/bookmarkable.js';\n\nfunction testConsolidateRests() {\n  \/\/ Two quarter rests should become a half rest\n  const test1 = [[\n    { notes: [{ duration: 1, rest: true }] },\n    { notes: [{ duration: 1, rest: true }] }\n  ]];\n  const result = consolidateRests(test1);\n  console.assert(result[0].length === 1, 'Should have 1 beat');\n  console.assert(result[0][0].notes[0].duration === 2, 'Should be a half rest');\n  \n  console.log('consolidateRests: passed');\n}\n<\/pre>\n<p>The main test runner is a simple function that calls each test:<\/p>\n<pre class=\"prettyprint\">export function test() {\n  testConsolidateRests();\n  testGenerateRhythm();\n  testMotivicRepetition();\n  testAcrossBeatTies();\n  testStress();  \/\/ 99 random examples\n  testParseSettingsFromHash();\n  testTranslateToAbc();\n  testSettingsRoundTrip();\n  testLevelOptions();\n  testPatterns();\n  testPatternsToObjects();\n  testBuildExerciseOptions();\n  testMetronomeFunctions();\n\n  console.log('\\nAll tests passed!');\n}\n<\/pre>\n<p>I know I could have separate test files for each module, but meh, one big file it is, for now.<\/p>\n<p>Running the tests is this one-liner:<\/p>\n<pre class=\"prettyprint\">node -e \"import('.\/test\/test-node.js').then(m =&gt; m.test())\"\n<\/pre>\n<p>Config? Test runner? Nah, just JavaScript.<\/p>\n<h3 id=\"node-js-coverage-with-c8\">Node.js coverage with c8<\/h3>\n<p>For coverage, I use <a href=\"https:\/\/github.com\/bcoe\/c8\">c8<\/a>, which wraps Node.js's built-in V8 coverage. And it works with <code>npx<\/code> - no local installation needed:<\/p>\n<pre class=\"prettyprint\">npx c8 --include=src --reporter=text node -e \"import('.\/test\/test-node.js').then(m =&gt; m.test())\"\n<\/pre>\n<p>Output:<\/p>\n<pre class=\"prettyprint\">consolidateRests: 10\/10 tests passed\ngenerateRhythm: 11\/11 time signatures passed\nmotivicRepetition: 11\/11 tests passed\n...\nAll tests passed!\n-----------------|---------|----------|---------|---------\nFile             | % Stmts | % Branch | % Funcs | % Lines\n-----------------|---------|----------|---------|---------\nAll files        |   79.41 |    91.89 |   65.38 |   79.41\n abchelpers.js   |   73.11 |    97.56 |      25 |   73.11\n bookmarkable.js |   96.25 |    87.17 |     100 |   96.25\n music-lib.js    |   95.05 |    90.26 |     100 |   95.05\n patterns.js     |     100 |      100 |     100 |     100\n ...\n-----------------|---------|----------|---------|---------\n<\/pre>\n<p>BTW, <code>--include=src<\/code> is to say: only measure source files, no tests.<\/p>\n<h2 id=\"browser-tests-for-ui-and-integration\">Browser tests for UI and integration<\/h2>\n<p>The browser tests live in <code>test\/test-browser.js<\/code> and test everything that requires a DOM: rendering, user interactions, settings panels, keyboard shortcuts, audio playback.<\/p>\n<p>The test framework is similarly minimal:<\/p>\n<pre class=\"prettyprint\">export async function testInBrowser() {\n  const results = { passed: 0, failed: 0, tests: [] };\n\n  function assert(condition, message) {\n    if (condition) {\n      results.passed++;\n      results.tests.push({ status: 'pass', message });\n    } else {\n      results.failed++;\n      results.tests.push({ status: 'fail', message });\n    }\n  }\n\n  \/\/ Run all tests\n  await testBarlineCount(assert);\n  await testNotesExist(assert);\n  await testTempoInput(assert);\n  await testMetronomeOptions(assert);\n  \/\/ ... more test functions\n\n  return results;\n}\n<\/pre>\n<p>Each test function receives the <code>assert<\/code> function and tests a specific feature:<\/p>\n<pre class=\"prettyprint\">async function testBarlineCount(assert) {\n  for (const measureCount of [4, 8, 12]) {\n    await setSetting('#measures', measureCount);\n    const barCount = count('#paper svg .abcjs-bar');\n    assert(\n      barCount === measureCount,\n      `${measureCount} measures should have ${measureCount} barlines`\n    );\n  }\n}\n<\/pre>\n<h3 id=\"running-browser-tests\">Running browser tests<\/h3>\n<p>For development, I added a <code>#test<\/code> hash trigger. Navigate to <code>localhost:8000\/#test<\/code> and the tests auto-run, with results in the console:<\/p>\n<pre class=\"prettyprint\">\/\/ In app.js init()\nif (location.hash === '#test') {\n  import('..\/test\/test-browser.js').then(({ testInBrowser }) =&gt; testInBrowser());\n}\n<\/pre>\n<p>This is great for quick iteration - just refresh the page to re-run tests.<\/p>\n<p>Actually I also kept these in production, you know, to catch any minification or other build-introduced hiccups. Want to see the actual code in action? Check out <a href=\"https:\/\/sightread.org#test\">sightread.org#test<\/a> - all the tests run right in your browser with <code>#test<\/code> in the URL.<\/p>\n<h3 id=\"browser-coverage-with-puppeteer\">Browser coverage with Puppeteer<\/h3>\n<p>For automated coverage, I use Puppeteer's built-in JavaScript coverage API. The script (<code>test\/coverage.mjs<\/code>) is about 150 lines:<\/p>\n<pre class=\"prettyprint\">const browser = await puppeteer.launch({ headless: true });\nconst page = await browser.newPage();\n\n\/\/ Start coverage collection\nawait page.coverage.startJSCoverage();\n\n\/\/ Load page and run tests\nawait page.goto('http:\/\/localhost:8000\/');\nconst results = await page.evaluate(async () =&gt; {\n  const { testInBrowser } = await import('.\/test\/test-browser.js');\n  return await testInBrowser();\n});\n\n\/\/ Stop and collect coverage\nconst coverage = await page.coverage.stopJSCoverage();\n\n\/\/ Filter to source files only\nconst sourceFiles = coverage.filter(entry =&gt; \n  entry.url.includes('\/src\/')\n);\n\n\/\/ Calculate and display coverage\nfor (const entry of sourceFiles) {\n  const fileName = entry.url.split('\/').pop();\n  const totalSize = entry.text.length;\n  let coveredSize = 0;\n  for (const range of entry.ranges) {\n    coveredSize += range.end - range.start;\n  }\n  const percent = ((coveredSize \/ totalSize) * 100).toFixed(1);\n  console.log(`${fileName}: ${percent}%`);\n}\n<\/pre>\n<p>The coverage output:<\/p>\n<pre class=\"prettyprint\">File                    | Covered | Total   | %\n------------------------|---------|---------|--------\napp.js                  |    9232 |   13259 |  69.6%\nbookmarkable.js         |    6245 |    6301 |  99.1%\nmusic-lib.js            |   22147 |   23390 |  94.7%\npatterns.js             |   13275 |   13275 | 100.0%\n...\n------------------------|---------|---------|--------\nTotal                   |   83648 |   94605 |  88.4%\n<\/pre>\n<p>Note: Puppeteer's coverage is byte-based (which bytes of the source were executed), not line-based. It's a slightly different metric than c8's, but just as useful.<\/p>\n<h2 id=\"enforcing-coverage-at-build-time\">Enforcing coverage at build time<\/h2>\n<p>Finally, in <code>build.js<\/code>, the tests run <em>before<\/em> any build steps, and coverage thresholds are enforced:<\/p>\n<pre class=\"prettyprint\">\/\/ Run Node.js tests with coverage\nexecSync('npx c8 --include=src --reporter=text node -e \"import(\\'.\/test\/test-node.js\\').then(m =&gt; m.test())\"', \n  { stdio: 'inherit' });\n\n\/\/ Check Node.js coverage threshold (75% lines)\ntry {\n  execSync('npx c8 --include=src check-coverage --lines 75', { stdio: 'pipe' });\n} catch (error) {\n  console.error('Node.js coverage below 75%! Build aborted.');\n  process.exit(1);\n}\n\n\/\/ Run browser tests and check coverage\nconst browserResult = execSync('node test\/coverage.mjs', { encoding: 'utf8' });\nconst totalMatch = browserResult.match(\/Total\\s+\\|\\s+\\d+\\s+\\|\\s+\\d+\\s+\\|\\s+([\\d.]+)%\/);\nif (totalMatch) {\n  const browserCoverage = parseFloat(totalMatch[1]);\n  if (browserCoverage &lt; 85) {\n    console.error(`Browser coverage ${browserCoverage}% is below 85%! Build aborted.`);\n    process.exit(1);\n  }\n}\n<\/pre>\n<p>If tests fail or coverage drops below the threshold, the build fails. No mo' shipping untested code.<\/p>\n<h2 id=\"some-numbers\">Some numbers<\/h2>\n<p>Currently, sightread.org has:<\/p>\n<ul>\n<li>Node.js tests: 13 test suites, including a stress test with 99 randomly generated examples<\/li>\n<li>Browser tests: 150 assertions covering rendering, UI interactions, audio playback, and URL handling<\/li>\n<li>Coverage: 79% Node.js, 88% browser. The audio stuff is hard to test.<\/li>\n<li>Ratio of production LOC vs LOC that test them is 55 to 45. So half of my code tests the other half. Ain't software development funny that way?<\/li>\n<\/ul>\n<h2 id=\"trade-offs\">Trade-offs<\/h2>\n<h3 id=\"i-gave-up\">I gave up...<\/h3>\n<ul>\n<li>Fancy APIs like <code>expect(value).toBeGreaterThan(5)<\/code><\/li>\n<li>Mocking libraries (I use real implementations or simple stubs)<\/li>\n<li>Snapshot testing (not needed for this project)<\/li>\n<li>IDE integrations for test discovery<\/li>\n<\/ul>\n<h3 id=\"in-order-to-gain\">...in order to gain:<\/h3>\n<ul>\n<li>Zero dependencies (and their updates)<\/li>\n<li>Tests run fast<\/li>\n<li>A testing setup I can understand<\/li>\n<li>Tests that work exactly like production code (ES modules, same import paths)<\/li>\n<\/ul>\n<p>Is this approach for everyone? Shirley, not. If you're on a team of 150 engineers (or 5?), you probably want a Jest-like ecosystem. But for a solo project where you want to understand every line of your toolchain, DIY testing works like a charm.<\/p>\n<p>No configuration files, no plugins. Just functions that call other functions and... well, <em>test<\/em> if the results make sense.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>This is what turns out to be a part 3 of a series about building sightread.org with minimal tooling: Part 1: Import JavaScript like it&#8217;s 2026 Part 2: Maximally Minimal Build Process The previous posts covered how I structure js modules for parallel loading and how to build a production-ready app with a 200-line build [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":[],"categories":[5],"tags":[],"_links":{"self":[{"href":"https:\/\/www.phpied.com\/wp-json\/wp\/v2\/posts\/2184"}],"collection":[{"href":"https:\/\/www.phpied.com\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.phpied.com\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.phpied.com\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.phpied.com\/wp-json\/wp\/v2\/comments?post=2184"}],"version-history":[{"count":0,"href":"https:\/\/www.phpied.com\/wp-json\/wp\/v2\/posts\/2184\/revisions"}],"wp:attachment":[{"href":"https:\/\/www.phpied.com\/wp-json\/wp\/v2\/media?parent=2184"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.phpied.com\/wp-json\/wp\/v2\/categories?post=2184"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.phpied.com\/wp-json\/wp\/v2\/tags?post=2184"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}