Skip to main content

Scoring an EPA 608 Practice Test Like the Real Exam: Per-Section Pass Logic in JavaScript

Updated 8 min readMarkdown
Part of seriesEPA 608 Practice Test Engineering

Most quiz engines end with one number: "You scored 84%." For an EPA 608 practice test, that number is close to useless, and it can be actively misleading.

The real EPA Section 608 technician exam is four separate tests stapled together: Core, Type I, Type II and Type III, 25 questions each. Each section is graded on its own. You need 18 of 25 (72%) on a section to pass it. What certification you walk away with depends on which sections you passed, not on your overall percentage.

So a candidate can score 92 out of 100 and still not be Universal certified. Another can score 76 out of 100 and be Universal. If your practice test only shows the total, the first person thinks they're done and the second thinks they're in trouble. Both are wrong.

This post builds the scoring function I wish I'd written first: it takes a list of questions and a set of responses, and it returns per-section results plus the certification those results actually earn.

The rules we need to encode

Stripped down to what matters for code:

  1. Four sections: Core, Type I, Type II, Type III.
  2. A section passes at 18 correct out of 25.
  3. Core is a prerequisite. Passing Type II without Core earns nothing.
  4. Core + Type I = Type I certified. Core + Type II = Type II. Core + Type III = Type III. Any combination is possible.
  5. Core + all three types = Universal.

Two extra cases show up in practice software that don't exist on the real exam:

  • Short practice sets. Users often drill 10 or 20 questions from one section. "18 correct" doesn't make sense for a 10-question set, so the pass mark has to scale.
  • Sections that weren't attempted. A Type II-only drill has zero Type I questions. That section is "not attempted," not "failed."

Data shape

Each question carries its section. Responses are a map from question id to the option the user picked.

const question = { id: 'TYPE2_015', section: 'Type II', answer: '0 inches Hg', options: [/* ... */] };

const responses = { TYPE2_015: '0 inches Hg', CORE_003: 'Two' };

Comparing by option text instead of option index matters here, and I'll come back to why at the end.

The scoring function

const SECTIONS = ['Core', 'Type I', 'Type II', 'Type III'];
const PASS_CORRECT = 18; // out of 25 per section

function scoreExam(questions, responses) {
  const bySection = Object.fromEntries(
    SECTIONS.map(s => [s, { correct: 0, total: 0, missed: [] }])
  );

  for (const q of questions) {
    const bucket = bySection[q.section];
    if (!bucket) throw new Error(`Unknown section "${q.section}" on ${q.id}`);
    bucket.total++;
    if (responses[q.id] === q.answer) bucket.correct++;
    else bucket.missed.push(q.id);
  }

  for (const s of SECTIONS) {
    const b = bySection[s];
    b.attempted = b.total > 0;
    b.passed = b.attempted && b.correct >= passMark(b.total);
  }

  return { bySection, certification: certificationFor(bySection) };
}

// 18/25 scaled for practice sets that are not exactly 25 questions
function passMark(total) {
  return Math.ceil(total * PASS_CORRECT / 25);
}

A few decisions worth calling out:

Unknown sections throw. It's tempting to silently skip a question with section: 'Type 2' or section: 'TypeII'. Don't. A typo in the data would quietly shrink a section to 24 questions and shift the pass mark. Failing loudly during development is cheaper than a wrong "PASS" shown to someone who is about to pay for the real exam.

Unanswered questions count as wrong. responses[q.id] is undefined for a skipped question, which never equals the answer. That matches the real exam: a blank is a miss.

passMark uses Math.ceil. For 25 questions it returns exactly 18. For a 10-question drill it returns 8 (7.2 rounded up), and for 20 it returns 15 (14.4 rounded up). Rounding up keeps the practice bar at or slightly above 72%, never below it. If you round to nearest, a 10-question set would pass at 7/10 = 70%, which is softer than the real thing.

Turning sections into a certification

function certificationFor(bySection) {
  if (!bySection.Core.passed) return 'None (Core not passed)';
  const types = ['Type I', 'Type II', 'Type III'].filter(t => bySection[t].passed);
  if (types.length === 3) return 'Universal';
  if (types.length === 0) return 'None yet (Core passed, no type passed)';
  return types.join(' + ');
}

The Core check comes first and short-circuits everything. That's the rule people forget most often, both test-takers and developers. I've seen practice apps that report "Type II: PASS" in green when Core failed, which is technically true for the section and completely false for the certificate.

Tests that encode the edge cases

The tests are the actual spec here. Each one is a situation a real candidate ends up in.

const assert = require('assert');
const make = (section, n) => Array.from({ length: n }, (_, i) =>
  ({ id: `${section}-${i}`, section, answer: 'ok' }));
const exam = SECTIONS.flatMap(s => make(s, 25));
const answerFirst = (section, k) => Object.fromEntries(
  exam.filter(q => q.section === section).slice(0, k).map(q => [q.id, 'ok']));

// 92 / 100 overall, but Type III only 17/25
const r1 = { ...answerFirst('Core', 25), ...answerFirst('Type I', 25),
             ...answerFirst('Type II', 25), ...answerFirst('Type III', 17) };
assert.strictEqual(scoreExam(exam, r1).certification, 'Type I + Type II');

// 76 / 100 overall, every section exactly 19
const r2 = Object.assign({}, ...SECTIONS.map(s => answerFirst(s, 19)));
assert.strictEqual(scoreExam(exam, r2).certification, 'Universal');

// Core fails at 17 -> nothing else counts
const r3 = { ...answerFirst('Core', 17), ...answerFirst('Type II', 25) };
assert.strictEqual(scoreExam(exam, r3).certification, 'None (Core not passed)');

assert.strictEqual(passMark(25), 18);
assert.strictEqual(passMark(10), 8);
assert.strictEqual(passMark(20), 15);

The first two cases are the ones from the intro. 92% overall misses Universal by a single question in Type III. 76% overall gets Universal because it clears every section. If your result screen can't express that, it's teaching the wrong lesson.

The third case is the "Core gatekeeper" rule: a perfect Type II section is worth nothing until Core passes.

Rendering the result

Once you have per-section data, the result screen almost writes itself. The one design choice I'd push for: show the margin, not just pass/fail.

function describeSection(name, b) {
  if (!b.attempted) return `${name}: not attempted`;
  const need = passMark(b.total);
  const margin = b.correct - need;
  const status = b.passed ? 'PASS' : 'FAIL';
  const note = margin === 0 ? ' (no room to spare)'
    : margin > 0 ? ` (${margin} above the line)`
    : ` (${-margin} short)`;
  return `${name}: ${b.correct}/${b.total} ${status}${note}`;
}
Core: 21/25 PASS (3 above the line)
Type I: 18/25 PASS (no room to spare)
Type II: 16/25 FAIL (2 short)
Type III: not attempted
Certification: Type I

"18/25 PASS" and "25/25 PASS" are very different states for someone deciding whether to book the exam. A candidate sitting exactly on the line passes this practice set and fails the real one on a bad day. Showing the margin makes that visible without any extra explanation.

Why responses store option text, not index

Back to the data shape. It's common to store a response as 2 ("the user picked the third option"). That breaks the moment you shuffle answer options per attempt, which you should do. Otherwise people learn that the answer to the recovery-cylinder question is "the second one" instead of learning the 80% fill rule.

If options are shuffled at render time, index 2 means something different on every attempt, and old responses in storage or analytics become meaningless. Storing the option text (or a stable option id) keeps scoring independent of display order. Compare responses[q.id] === q.answer and the shuffle can do whatever it wants.

Things this doesn't model (on purpose)

  • Retakes. Certifying organizations generally let you retake only the sections you failed, but the details vary by organization and proctoring method. I keep that out of the scoring function and in the UI copy, with a pointer to the certifying organization's own rules.
  • Type I open-book variants. Some organizations offer Type I under different conditions. Same answer: don't hard-code what you can't keep up to date.
  • Time limits. Scoring and timing are separate concerns. A timer that expires should just stop collecting responses; this function scores whatever it receives.

Wrap-up

Everything here fits in about 50 lines, and it's the difference between an EPA 608 practice test that tells people where they really stand and one that gives them a comforting number. If you're building any exam simulator, the same pattern applies: find out how the real exam is actually graded, model that first, and treat the overall percentage as a footnote.

The full file with the tests is runnable with plain node score.js, no dependencies.

No comments yet. Be the first to comment.