Video Streaming Testing: HLS, DASH, and Media Player Integration Tests
Video streaming applications involve multiple components: transcoding pipelines, manifest generation, CDN delivery, and player integration. Each layer needs testing.
Testing the Transcoding Pipeline
// services/transcoder.js
import { spawn } from 'child_process'
import path from 'path'
export async function transcodeToHLS(inputPath, outputDir) {
return new Promise((resolve, reject) => {
const ffmpeg = spawn('ffmpeg', [
'-i', inputPath,
'-codec:', 'copy',
'-start_number', '0',
'-hls_time', '10',
'-hls_list_size', '0',
'-f', 'hls',
path.join(outputDir, 'output.m3u8')
])
let stderr = ''
ffmpeg.stderr.on('data', data => { stderr += data.toString() })
ffmpeg.on('close', (code) => {
if (code === 0) resolve({ manifestPath: path.join(outputDir, 'output.m3u8') })
else reject(new Error(`FFmpeg failed: ${stderr}`))
})
})
}// services/transcoder.test.js
import { execSync } from 'child_process'
import fs from 'fs'
import path from 'path'
import os from 'os'
import { transcodeToHLS } from './transcoder'
let outputDir
beforeEach(() => {
outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hls-test-'))
})
afterEach(() => {
fs.rmSync(outputDir, { recursive: true })
})
test('generates HLS manifest from MP4', async () => {
// Use a small test video (generated with ffmpeg)
const testVideo = 'tests/fixtures/test-10s.mp4'
const result = await transcodeToHLS(testVideo, outputDir)
expect(fs.existsSync(result.manifestPath)).toBe(true)
const manifest = fs.readFileSync(result.manifestPath, 'utf-8')
expect(manifest).toContain('#EXTM3U')
expect(manifest).toContain('#EXT-X-VERSION')
expect(manifest).toContain('.ts') // Segment files
})
test('manifest references segments that exist on disk', async () => {
const testVideo = 'tests/fixtures/test-10s.mp4'
const result = await transcodeToHLS(testVideo, outputDir)
const manifest = fs.readFileSync(result.manifestPath, 'utf-8')
const segments = manifest.match(/output\d+\.ts/g) || []
expect(segments.length).toBeGreaterThan(0)
for (const segment of segments) {
const segPath = path.join(outputDir, segment)
expect(fs.existsSync(segPath)).toBe(true)
expect(fs.statSync(segPath).size).toBeGreaterThan(0)
}
})HLS Manifest Validation
// test-helpers/hls-validator.js
export function validateM3U8Manifest(content) {
const errors = []
if (!content.startsWith('#EXTM3U')) {
errors.push('Missing #EXTM3U header')
}
if (!content.includes('#EXT-X-VERSION')) {
errors.push('Missing #EXT-X-VERSION')
}
// Every EXTINF must be followed by a URI
const lines = content.split('\n')
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith('#EXTINF')) {
if (i + 1 >= lines.length || lines[i + 1].startsWith('#')) {
errors.push(`EXTINF at line ${i + 1} not followed by segment URI`)
}
}
}
// Check for EXT-X-ENDLIST in VOD content
if (!content.includes('#EXT-X-ENDLIST')) {
errors.push('VOD manifest missing #EXT-X-ENDLIST')
}
return errors
}
// In tests
test('generated manifest is valid HLS', async () => {
const manifest = fs.readFileSync(manifestPath, 'utf-8')
const errors = validateM3U8Manifest(manifest)
expect(errors).toHaveLength(0)
})Testing Adaptive Bitrate (Multi-Quality)
test('generates multi-bitrate HLS with master manifest', async () => {
const result = await transcodeMultiQuality('tests/fixtures/test-10s.mp4', outputDir)
const masterManifest = fs.readFileSync(result.masterManifestPath, 'utf-8')
// Master playlist should reference renditions
expect(masterManifest).toContain('#EXT-X-STREAM-INF')
expect(masterManifest).toContain('BANDWIDTH')
// Should have 360p, 720p, 1080p renditions
expect(masterManifest).toContain('360p/index.m3u8')
expect(masterManifest).toContain('720p/index.m3u8')
expect(masterManifest).toContain('1080p/index.m3u8')
// Each rendition playlist should exist and be valid
for (const quality of ['360p', '720p', '1080p']) {
const renditionPath = path.join(outputDir, quality, 'index.m3u8')
expect(fs.existsSync(renditionPath)).toBe(true)
const content = fs.readFileSync(renditionPath, 'utf-8')
const errors = validateM3U8Manifest(content)
expect(errors).toHaveLength(0)
}
})Testing Video Segment Delivery
// API test: segment delivery with Range requests
test('serves video segments with range header support', async () => {
const response = await request(app)
.get('/videos/output0.ts')
.set('Range', 'bytes=0-1023')
expect(response.status).toBe(206) // Partial Content
expect(response.headers['content-range']).toMatch(/^bytes 0-1023\/\d+$/)
expect(response.headers['accept-ranges']).toBe('bytes')
expect(response.body.length).toBe(1024)
})
test('serves full segment without Range header', async () => {
const response = await request(app).get('/videos/output0.ts')
expect(response.status).toBe(200)
expect(response.headers['content-type']).toBe('video/MP2T')
})Browser Playback Testing with Playwright
// e2e/video-player.spec.ts
import { test, expect } from '@playwright/test'
test('video starts playing after clicking play', async ({ page }) => {
await page.goto('/watch/video-123')
const video = page.locator('video')
await expect(video).toBeVisible()
// Video should be paused initially
const isPaused = await video.evaluate((el) => el.paused)
expect(isPaused).toBe(true)
// Click play
await page.click('[data-testid="play-button"]')
// Video should start playing
await page.waitForFunction(() => {
const v = document.querySelector('video')
return v && !v.paused && v.currentTime > 0
}, { timeout: 5000 })
const isPlaying = await video.evaluate((el) => !el.paused)
expect(isPlaying).toBe(true)
})
test('video player shows buffering indicator during load', async ({ page }) => {
// Throttle network to trigger buffering
await page.context().route('**/*.ts', async (route) => {
await new Promise(r => setTimeout(r, 2000))
await route.continue()
})
await page.goto('/watch/video-123')
await page.click('[data-testid="play-button"]')
// Buffering indicator should appear
await expect(page.locator('[data-testid="buffering-spinner"]')).toBeVisible()
})Testing CDN Cache Headers
test('video segments have correct cache headers', async () => {
const response = await request(app).get('/videos/output0.ts')
// Segments are immutable — cache forever
expect(response.headers['cache-control']).toBe('public, max-age=31536000, immutable')
})
test('manifest has short cache TTL for live streams', async () => {
const response = await request(app).get('/live/room-1/index.m3u8')
// Live manifests update frequently
expect(response.headers['cache-control']).toBe('no-cache, no-store')
})
test('master manifest has medium cache TTL', async () => {
const response = await request(app).get('/videos/master.m3u8')
// Master doesn't change often but can
const cc = response.headers['cache-control']
expect(cc).toContain('max-age=')
const maxAge = parseInt(cc.match(/max-age=(\d+)/)?.[1] || '0')
expect(maxAge).toBeGreaterThanOrEqual(60)
expect(maxAge).toBeLessThanOrEqual(3600)
})Summary
Video streaming testing covers:
- Transcoding unit tests — validate FFmpeg output, manifest format, segment existence
- HLS manifest validation — correct headers, segment references, EXT-X-ENDLIST
- Range request support — 206 responses, Content-Range headers
- Browser playback — Playwright tests for play/pause, buffering states
- CDN cache headers — immutable segments, short TTL for live manifests
Generate a small test video file (2-3 seconds) with FFmpeg for use in unit tests. Keep it in version control as a fixture — it's small and enables reproducible transcoding tests.