Most testing tutorials ignore e-learning completely. Here's how to build a Playwright test suite that validates your SCORM packages actually work across LMS platforms.
Why E-Learning Testing Is Different
If you've ever published a SCORM package to an LMS and watched it silently fail — no completion recorded, quiz scores vanishing, navigation broken — you know the pain. E-learning content doesn't behave like a typical web app. It runs inside an LMS-provided iframe, communicates through a JavaScript API (the SCORM Runtime), and its behavior changes depending on which LMS hosts it.
Manual QA across even 3-4 LMS platforms is slow and error-prone. In this tutorial, I'll walk you through setting up Playwright to automate SCORM package testing — from basic content loading to verifying API calls and completion status.
Prerequisites
Before we start, make sure you have:
- Node.js 18+ installed
-
Playwright (
npm init playwright@latest) - A SCORM 1.2 or 2004 package (a .zip file containing your e-learning content)
- A local LMS for testing — we'll use SCORM Cloud (free tier) or a simple SCORM API shim
Step 1: Set Up a Local SCORM Runtime Shim
Testing SCORM content requires an API that mimics what an LMS provides. Rather than spinning up a full Moodle instance, we'll create a lightweight shim.
Create a file called scorm-api-shim.js:
// scorm-api-shim.js
// Mimics the SCORM 1.2 Runtime API that an LMS would expose
window.API = {
_data: {},
_initialized: false,
_calls: [],
LMSInitialize: function(param) {
this._initialized = true;
this._calls.push({ method: 'LMSInitialize', param, timestamp: Date.now() });
console.log('[SCORM] LMSInitialize called');
return "true";
},
LMSGetValue: function(key) {
this._calls.push({ method: 'LMSGetValue', key, timestamp: Date.now() });
return this._data[key] || "";
},
LMSSetValue: function(key, value) {
this._data[key] = value;
this._calls.push({ method: 'LMSSetValue', key, value, timestamp: Date.now() });
console.log(`[SCORM] SetValue: ${key} = ${value}`);
return "true";
},
LMSCommit: function(param) {
this._calls.push({ method: 'LMSCommit', param, timestamp: Date.now() });
return "true";
},
LMSFinish: function(param) {
this._calls.push({ method: 'LMSFinish', param, timestamp: Date.now() });
this._initialized = false;
return "true";
},
LMSGetLastError: function() { return "0"; },
LMSGetErrorString: function(code) { return "No error"; },
LMSGetDiagnostic: function(code) { return ""; }
};
This gives us a mock API that logs every SCORM call — which becomes our test assertion layer.
Step 2: Serve the SCORM Package Locally
Unzip your SCORM package and serve it with a simple HTTP server. Create serve-scorm.js:
// serve-scorm.js
const express = require('express');
const path = require('path');
const app = express();
// Serve the SCORM API shim at the parent level (LMS frame)
app.get('/lms', (req, res) => {
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Test LMS</title>
<script src="/scorm-api-shim.js"></script>
</head>
<body>
<iframe id="content-frame"
src="/scorm-content/index.html"
width="100%"
height="600px">
</iframe>
</body>
</html>
`);
});
app.use('/scorm-api-shim.js', express.static(path.join(__dirname, 'scorm-api-shim.js')));
app.use('/scorm-content', express.static(path.join(__dirname, 'unzipped-scorm-package')));
app.listen(3000, () => console.log('Test LMS running at http://localhost:3000/lms'));
Run it:
npm install express
node serve-scorm.js
Visit http://localhost:3000/lms — you should see your SCORM content loaded inside the iframe, with API calls logging in the console.
Step 3: Write Your First Playwright Test — Content Loads and Initializes
Create your test file at tests/scorm-basic.spec.js:
// tests/scorm-basic.spec.js
const { test, expect } = require('@playwright/test');
test.describe('SCORM Package - Basic Validation', () => {
test('should load content and call LMSInitialize', async ({ page }) => {
await page.goto('http://localhost:3000/lms');
// Wait for the content iframe to load
const frame = page.frameLocator('#content-frame');
await frame.locator('body').waitFor({ state: 'visible' });
// Check that LMSInitialize was called
const initCalled = await page.evaluate(() => {
return window.API._calls.some(c => c.method === 'LMSInitialize');
});
expect(initCalled).toBe(true);
});
test('should set lesson_status to incomplete or browsed on load', async ({ page }) => {
await page.goto('http://localhost:3000/lms');
const frame = page.frameLocator('#content-frame');
await frame.locator('body').waitFor({ state: 'visible' });
// Give the content a moment to make its initial API calls
await page.waitForTimeout(2000);
const lessonStatus = await page.evaluate(() => {
return window.API._data['cmi.core.lesson_status'];
});
// SCORM content typically sets status to 'incomplete' or 'browsed' on launch
expect(['incomplete', 'browsed', 'not attempted']).toContain(lessonStatus);
});
});
Run the test:
npx playwright test tests/scorm-basic.spec.js
Step 4: Test Quiz Interaction Tracking
If your SCORM package has a quiz, you need to verify that interaction data is being recorded correctly. This is where most LMS compatibility bugs live.
// tests/scorm-quiz.spec.js
const { test, expect } = require('@playwright/test');
test.describe('SCORM Package - Quiz Interactions', () => {
test('should record interaction data when answering a quiz question', async ({ page }) => {
await page.goto('http://localhost:3000/lms');
const frame = page.frameLocator('#content-frame');
// Navigate to the quiz slide (adjust selectors to your content)
// This will vary based on your authoring tool's output
await frame.locator('[data-slide="quiz-1"]').click();
// Select an answer
await frame.locator('.answer-option').first().click();
// Submit the answer
await frame.locator('.submit-btn').click();
// Verify interaction was recorded via SCORM API
const interactions = await page.evaluate(() => {
return window.API._calls.filter(c =>
c.method === 'LMSSetValue' &&
c.key.startsWith('cmi.interactions')
);
});
// Should have at least one interaction recorded
expect(interactions.length).toBeGreaterThan(0);
// Verify the interaction has a valid type
const interactionType = interactions.find(i => i.key.includes('.type'));
expect(['choice', 'true-false', 'fill-in', 'matching', 'sequencing'])
.toContain(interactionType?.value);
});
test('should calculate and commit score after quiz completion', async ({ page }) => {
await page.goto('http://localhost:3000/lms');
// ... navigate through quiz and answer all questions ...
// After completing the quiz, check for score
const scoreRaw = await page.evaluate(() => {
return window.API._data['cmi.core.score.raw'];
});
const scoreMax = await page.evaluate(() => {
return window.API._data['cmi.core.score.max'];
});
// Score should be a valid number
expect(Number(scoreRaw)).not.toBeNaN();
expect(Number(scoreMax)).toBeGreaterThan(0);
expect(Number(scoreRaw)).toBeLessThanOrEqual(Number(scoreMax));
// Verify LMSCommit was called after scoring
const commitCalls = await page.evaluate(() => {
return window.API._calls.filter(c => c.method === 'LMSCommit');
});
expect(commitCalls.length).toBeGreaterThan(0);
});
});
Step 5: Test Completion and Finish Sequence
The most common SCORM bug: content that never calls LMSFinish or sets lesson_status to completed/passed. This breaks LMS reporting.
// tests/scorm-completion.spec.js
const { test, expect } = require('@playwright/test');
test.describe('SCORM Package - Completion Flow', () => {
test('should set lesson_status to completed/passed after full navigation', async ({ page }) => {
await page.goto('http://localhost:3000/lms');
const frame = page.frameLocator('#content-frame');
// Navigate through all slides (adjust to your content structure)
const nextButton = frame.locator('.next-btn, [aria-label="Next"]');
let hasNext = true;
while (hasNext) {
try {
await nextButton.click({ timeout: 3000 });
await page.waitForTimeout(500);
} catch {
hasNext = false;
}
}
// Check final lesson status
const finalStatus = await page.evaluate(() => {
return window.API._data['cmi.core.lesson_status'];
});
expect(['completed', 'passed']).toContain(finalStatus);
});
test('should call LMSFinish on content exit', async ({ page }) => {
await page.goto('http://localhost:3000/lms');
// Navigate through content...
// Then close or navigate away
await page.evaluate(() => {
// Simulate unload event
window.dispatchEvent(new Event('beforeunload'));
});
await page.waitForTimeout(1000);
const finishCalled = await page.evaluate(() => {
return window.API._calls.some(c => c.method === 'LMSFinish');
});
expect(finishCalled).toBe(true);
});
});
Step 6: Generate a SCORM API Call Report
The real power of this approach is the ability to dump every SCORM API call for debugging. Add this utility:
// tests/helpers/scorm-report.js
async function generateSCORMReport(page) {
const calls = await page.evaluate(() => window.API._calls);
const data = await page.evaluate(() => window.API._data);
console.log('\n=== SCORM API Call Log ===');
calls.forEach((call, index) => {
const time = new Date(call.timestamp).toISOString().split('T')[1];
if (call.key) {
console.log(`${index + 1}. [${time}] ${call.method}("${call.key}"${call.value ? ', "' + call.value + '"' : ''})`);
} else {
console.log(`${index + 1}. [${time}] ${call.method}()`);
}
});
console.log('\n=== Final SCORM Data Model ===');
Object.entries(data).forEach(([key, value]) => {
console.log(` ${key}: ${value}`);
});
return { calls, data };
}
module.exports = { generateSCORMReport };
Use it in your tests:
const { generateSCORMReport } = require('./helpers/scorm-report');
test.afterEach(async ({ page }) => {
await generateSCORMReport(page);
});
Step 7: Run Across Multiple Browser Contexts (Simulating Different LMS Environments)
Different LMS platforms use different iframe embedding strategies. Test your content across configurations:
// playwright.config.js
module.exports = {
projects: [
{
name: 'chromium-default',
use: { browserName: 'chromium' },
},
{
name: 'firefox',
use: { browserName: 'firefox' },
},
{
name: 'webkit-safari',
use: { browserName: 'webkit' },
},
],
};
Run all:
npx playwright test --reporter=html
This gives you a visual HTML report showing pass/fail across browsers — which maps roughly to how your content will behave across different LMS platforms that use different embedded browser engines.
What This Gets You
With this setup, you can now:
-
Catch SCORM API bugs before publishing — missing
LMSInitialize, broken completion triggers, invalid interaction data - Debug LMS-specific failures by examining the full API call log
- Run regression tests every time content is updated or the authoring tool ships a new version
- Automate cross-browser validation that would take hours to do manually
In our team, this approach cut QA time for SCORM packages by roughly 70% and caught a category of bugs — specifically, race conditions in LMSSetValue calls during quiz scoring — that manual testing had missed for months.
Next Steps
From here, you can extend this framework to:
- Test SCORM 2004 packages (replace
window.APIwithwindow.API_1484_11) - Test xAPI (Tin Can) statements by intercepting
fetch/XMLHttpRequestcalls to the LRS endpoint - Integrate into CI/CD with GitHub Actions so every content build gets validated automatically
- Add accessibility checks using
@axe-core/playwrightalongside SCORM validation
If you're building or testing e-learning content and you've run into weird LMS bugs, I'd love to hear about them — the edge cases in this space are wild.
I'm a senior software engineer with 11 years in e-learning technology. I write about the tools and techniques behind enterprise content authoring. Find me on LinkedIn.
Top comments (1)
SCORM testing is a great niche for Playwright because the important behavior is workflow state, not just DOM rendering. Launch, progress tracking, completion, suspend data, and LMS communication are all easy to miss with manual spot checks. Automating that path probably catches the failures users actually feel.