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 (0)