Overview
Testing your Scully plugins ensures they work correctly, handle edge cases gracefully, and remain maintainable over time. This guide covers testing strategies for all plugin types using Jest, Jasmine, or other testing frameworks.Testing Setup
Install Dependencies
npm install --save-dev jest @types/jest ts-jest
Jest Configuration
Create or updatejest.config.js:
module.exports = {
preset: 'jest-preset-angular',
setupFilesAfterEnv: ['<rootDir>/setup-jest.ts'],
testPathIgnorePatterns: [
'/node_modules/',
'/dist/',
'/scully/',
],
coveragePathIgnorePatterns: [
'/node_modules/',
'/dist/',
],
moduleNameMapper: {
'@scullyio/scully': '<rootDir>/node_modules/@scullyio/scully',
},
globals: {
'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json',
},
},
};
Testing Router Plugins
Basic Router Plugin Test
// scully/plugins/__tests__/api-router.plugin.spec.ts
import { findPlugin, HandledRoute } from '@scullyio/scully';
import '../api-router.plugin';
describe('apiRouterPlugin', () => {
let plugin: any;
beforeAll(() => {
plugin = findPlugin('router', 'api');
});
it('should be registered', () => {
expect(plugin).toBeDefined();
});
it('should return an array of HandledRoutes', async () => {
const config = {
type: 'api',
url: 'https://jsonplaceholder.typicode.com/posts',
};
const routes = await plugin('/blog/:slug', config);
expect(Array.isArray(routes)).toBe(true);
expect(routes.length).toBeGreaterThan(0);
});
it('should generate correct route paths', async () => {
const config = {
type: 'api',
url: 'https://jsonplaceholder.typicode.com/posts',
};
const routes = await plugin('/blog/:slug', config);
const firstRoute = routes[0];
expect(firstRoute).toHaveProperty('route');
expect(firstRoute.route).toMatch(/^\/blog\//);
expect(firstRoute.type).toBe('api');
});
it('should include route data', async () => {
const config = {
type: 'api',
url: 'https://jsonplaceholder.typicode.com/posts',
};
const routes = await plugin('/blog/:slug', config);
const firstRoute = routes[0];
expect(firstRoute).toHaveProperty('data');
expect(firstRoute.data).toBeDefined();
});
it('should handle API errors gracefully', async () => {
const config = {
type: 'api',
url: 'https://invalid-url-that-does-not-exist.com/api',
};
const routes = await plugin('/blog/:slug', config);
// Should return fallback route instead of throwing
expect(routes).toEqual([{
route: '/blog/:slug',
type: 'api',
}]);
});
it('should handle missing configuration', async () => {
const config = {
type: 'api',
// Missing url
};
await expect(
plugin('/blog/:slug', config)
).rejects.toThrow();
});
});
Mocking HTTP Requests
import { findPlugin } from '@scullyio/scully';
import * as httpUtils from '@scullyio/scully/utils/httpGetJson';
import '../api-router.plugin';
jest.mock('@scullyio/scully/utils/httpGetJson');
describe('apiRouterPlugin with mocks', () => {
let plugin: any;
const mockHttpGetJson = httpUtils.httpGetJson as jest.MockedFunction<
typeof httpUtils.httpGetJson
>;
beforeAll(() => {
plugin = findPlugin('router', 'api');
});
beforeEach(() => {
mockHttpGetJson.mockClear();
});
it('should fetch data from configured URL', async () => {
const mockData = [
{ id: 1, slug: 'post-1', title: 'Post 1' },
{ id: 2, slug: 'post-2', title: 'Post 2' },
];
mockHttpGetJson.mockResolvedValue(mockData);
const config = {
type: 'api',
url: 'https://api.example.com/posts',
};
const routes = await plugin('/blog/:slug', config);
expect(mockHttpGetJson).toHaveBeenCalledWith(
'https://api.example.com/posts',
expect.any(Object)
);
expect(routes).toHaveLength(2);
});
it('should handle empty API response', async () => {
mockHttpGetJson.mockResolvedValue([]);
const config = {
type: 'api',
url: 'https://api.example.com/posts',
};
const routes = await plugin('/blog/:slug', config);
expect(routes).toHaveLength(0);
});
});
Testing Render Plugins
Testing postProcessByHtml Plugins
// scully/plugins/__tests__/seo.plugin.spec.ts
import { findPlugin, HandledRoute } from '@scullyio/scully';
import { setPluginConfig } from '@scullyio/scully';
import '../seo.plugin';
describe('seoPlugin', () => {
let plugin: any;
beforeAll(() => {
plugin = findPlugin('postProcessByHtml', 'seo');
setPluginConfig('seo', {
siteName: 'Test Site',
siteUrl: 'https://test.com',
twitterHandle: '@testsite',
});
});
it('should be registered', () => {
expect(plugin).toBeDefined();
});
it('should add meta tags to HTML', async () => {
const html = '<html><head></head><body></body></html>';
const route: HandledRoute = {
route: '/test',
type: 'test',
data: {
title: 'Test Page',
description: 'Test description',
},
};
const result = await plugin(html, route);
expect(result).toContain('<meta name="title" content="Test Page">');
expect(result).toContain('<meta name="description"');
expect(result).toContain('<meta property="og:title"');
expect(result).toContain('<meta property="twitter:card"');
});
it('should handle missing route data', async () => {
const html = '<html><head></head><body></body></html>';
const route: HandledRoute = {
route: '/test',
type: 'test',
};
const result = await plugin(html, route);
expect(result).toBeTruthy();
expect(result).toContain('<head>');
});
it('should use default values', async () => {
const html = '<html><head></head><body></body></html>';
const route: HandledRoute = {
route: '/test',
type: 'test',
data: {},
};
const result = await plugin(html, route);
expect(result).toContain('Test Site');
expect(result).toContain('https://test.com');
});
it('should not modify HTML structure', async () => {
const html = '<html><head><title>Original</title></head><body><p>Content</p></body></html>';
const route: HandledRoute = {
route: '/test',
type: 'test',
data: { title: 'New Title' },
};
const result = await plugin(html, route);
expect(result).toContain('<body>');
expect(result).toContain('<p>Content</p>');
expect(result).toContain('</html>');
});
});
Testing postProcessByDom Plugins
// scully/plugins/__tests__/toc.plugin.spec.ts
import { findPlugin, HandledRoute } from '@scullyio/scully';
import { JSDOM } from 'jsdom';
import '../table-of-contents.plugin';
describe('tableOfContentsPlugin', () => {
let plugin: any;
beforeAll(() => {
plugin = findPlugin('postProcessByDom', 'addTableOfContents');
});
it('should add table of contents', async () => {
const html = `
<html>
<body>
<article>
<h1>Main Title</h1>
<h2>Section 1</h2>
<p>Content</p>
<h2>Section 2</h2>
<p>More content</p>
</article>
</body>
</html>
`;
const dom = new JSDOM(html);
const route: HandledRoute = {
route: '/test',
type: 'test',
};
const result = await plugin(dom, route);
const { document } = result.window;
const toc = document.querySelector('.table-of-contents');
expect(toc).toBeTruthy();
const links = toc?.querySelectorAll('a');
expect(links?.length).toBe(2);
});
it('should skip when no headings exist', async () => {
const html = '<html><body><p>No headings here</p></body></html>';
const dom = new JSDOM(html);
const route: HandledRoute = {
route: '/test',
type: 'test',
};
const result = await plugin(dom, route);
const { document } = result.window;
const toc = document.querySelector('.table-of-contents');
expect(toc).toBeFalsy();
});
it('should add IDs to headings', async () => {
const html = `
<html>
<body>
<article>
<h2>Section Without ID</h2>
</article>
</body>
</html>
`;
const dom = new JSDOM(html);
const route: HandledRoute = { route: '/test', type: 'test' };
const result = await plugin(dom, route);
const { document } = result.window;
const heading = document.querySelector('h2');
expect(heading?.id).toBeTruthy();
});
});
Testing File Handler Plugins
// scully/plugins/__tests__/markdown-handler.spec.ts
import { findPlugin, HandledRoute } from '@scullyio/scully';
import '../markdown-handler.plugin';
describe('markdownHandler', () => {
let plugin: any;
beforeAll(() => {
plugin = findPlugin('fileHandler', 'md');
});
it('should be registered', () => {
expect(plugin).toBeDefined();
});
it('should convert markdown to HTML', async () => {
const markdown = '# Hello World\n\nThis is **bold** text.';
const route: HandledRoute = {
route: '/test',
type: 'contentFolder',
};
const html = await plugin(markdown, route);
expect(html).toContain('<h1>');
expect(html).toContain('Hello World');
expect(html).toContain('<strong>bold</strong>');
});
it('should handle code blocks', async () => {
const markdown = '```typescript\nconst x = 1;\n```';
const route: HandledRoute = {
route: '/test',
type: 'contentFolder',
};
const html = await plugin(markdown, route);
expect(html).toContain('<pre>');
expect(html).toContain('<code>');
expect(html).toContain('const x = 1');
});
it('should handle empty content', async () => {
const route: HandledRoute = {
route: '/test',
type: 'contentFolder',
};
const html = await plugin('', route);
expect(html).toBe('');
});
it('should handle links', async () => {
const markdown = '[Link Text](https://example.com)';
const route: HandledRoute = {
route: '/test',
type: 'contentFolder',
};
const html = await plugin(markdown, route);
expect(html).toContain('<a href="https://example.com">');
expect(html).toContain('Link Text');
});
it('should handle lists', async () => {
const markdown = '- Item 1\n- Item 2\n- Item 3';
const route: HandledRoute = {
route: '/test',
type: 'contentFolder',
};
const html = await plugin(markdown, route);
expect(html).toContain('<ul>');
expect(html).toContain('<li>');
expect(html).toContain('Item 1');
});
});
Testing Plugin Configuration
import {
findPlugin,
setPluginConfig,
getPluginConfig
} from '@scullyio/scully';
import '../configurable.plugin';
describe('Plugin Configuration', () => {
let plugin: any;
beforeAll(() => {
plugin = findPlugin('postProcessByHtml', 'configurable');
});
beforeEach(() => {
// Reset to defaults before each test
setPluginConfig('configurable', {
enabled: true,
prefix: 'default-',
});
});
it('should use default configuration', async () => {
const html = '<html><head></head><body></body></html>';
const route = { route: '/test', type: 'test' };
const result = await plugin(html, route);
expect(result).toContain('default-');
});
it('should respect custom configuration', async () => {
setPluginConfig('configurable', {
enabled: true,
prefix: 'custom-',
});
const html = '<html><head></head><body></body></html>';
const route = { route: '/test', type: 'test' };
const result = await plugin(html, route);
expect(result).toContain('custom-');
});
it('should retrieve configuration', () => {
setPluginConfig('configurable', {
enabled: false,
prefix: 'test-',
});
const config = getPluginConfig('configurable');
expect(config.enabled).toBe(false);
expect(config.prefix).toBe('test-');
});
});
Testing Error Handling
import { findPlugin, HandledRoute } from '@scullyio/scully';
import '../error-prone.plugin';
describe('Error Handling', () => {
let plugin: any;
beforeAll(() => {
plugin = findPlugin('postProcessByHtml', 'errorProne');
});
it('should handle malformed HTML gracefully', async () => {
const badHtml = '<html><head></head><body><div></body></html>';
const route: HandledRoute = { route: '/test', type: 'test' };
const result = await plugin(badHtml, route);
// Should not throw
expect(result).toBeTruthy();
expect(typeof result).toBe('string');
});
it('should return original HTML on error', async () => {
const html = '<html><head></head><body></body></html>';
const route: HandledRoute = {
route: '/test',
type: 'test',
data: { causeError: true },
};
const result = await plugin(html, route);
// Should return original HTML
expect(result).toBe(html);
});
it('should log errors without throwing', async () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
const html = '<html><head></head><body></body></html>';
const route: HandledRoute = {
route: '/test',
type: 'test',
data: { causeError: true },
};
await plugin(html, route);
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
Integration Tests
import { findPlugin, HandledRoute } from '@scullyio/scully';
import '../plugin1';
import '../plugin2';
import '../plugin3';
describe('Plugin Integration', () => {
it('should compose multiple plugins', async () => {
const plugin1 = findPlugin('postProcessByHtml', 'plugin1');
const plugin2 = findPlugin('postProcessByHtml', 'plugin2');
const plugin3 = findPlugin('postProcessByHtml', 'plugin3');
let html = '<html><head></head><body></body></html>';
const route: HandledRoute = { route: '/test', type: 'test' };
// Apply plugins in sequence
html = await plugin1(html, route);
html = await plugin2(html, route);
html = await plugin3(html, route);
// Verify all transformations were applied
expect(html).toContain('plugin1-marker');
expect(html).toContain('plugin2-marker');
expect(html).toContain('plugin3-marker');
});
});
Performance Testing
import { findPlugin, HandledRoute } from '@scullyio/scully';
import '../heavy.plugin';
describe('Plugin Performance', () => {
let plugin: any;
beforeAll(() => {
plugin = findPlugin('postProcessByHtml', 'heavy');
});
it('should process in reasonable time', async () => {
const html = '<html><head></head><body>' + 'x'.repeat(10000) + '</body></html>';
const route: HandledRoute = { route: '/test', type: 'test' };
const start = Date.now();
await plugin(html, route);
const duration = Date.now() - start;
// Should complete in under 1 second
expect(duration).toBeLessThan(1000);
});
it('should handle large HTML efficiently', async () => {
const largeHtml = '<html><head></head><body>' +
'<div>'.repeat(1000) + 'content' + '</div>'.repeat(1000) +
'</body></html>';
const route: HandledRoute = { route: '/test', type: 'test' };
const start = Date.now();
await plugin(largeHtml, route);
const duration = Date.now() - start;
console.log(`Large HTML processing time: ${duration}ms`);
expect(duration).toBeLessThan(5000);
});
});
Best Practices
1. Use Descriptive Test Names
// Good
it('should add meta tags when route has title and description', async () => {
// ...
});
// Avoid
it('works', async () => {
// ...
});
2. Test Edge Cases
describe('Edge Cases', () => {
it('should handle null input', async () => { /* ... */ });
it('should handle undefined route', async () => { /* ... */ });
it('should handle empty strings', async () => { /* ... */ });
it('should handle special characters', async () => { /* ... */ });
});
3. Use Test Fixtures
const fixtures = {
simpleHtml: '<html><head></head><body></body></html>',
withContent: '<html><head></head><body><p>Content</p></body></html>',
malformed: '<html><head></head><body><div></body></html>',
};
const mockRoutes = {
basic: { route: '/test', type: 'test' },
withData: {
route: '/test',
type: 'test',
data: { title: 'Test' }
},
};
4. Mock External Dependencies
jest.mock('@scullyio/scully/utils/httpGetJson', () => ({
httpGetJson: jest.fn(),
}));
5. Test in Isolation
beforeEach(() => {
// Reset state
jest.clearAllMocks();
// Reset configuration
setPluginConfig('myPlugin', defaultConfig);
});
Running Tests
# Run all tests
npm test
# Run specific test file
npm test -- plugins/my-plugin.spec.ts
# Run with coverage
npm test -- --coverage
# Run in watch mode
npm test -- --watch
Next Steps
- Review Getting Started
- Explore Router Plugins
- Learn about Render Plugins
- Study File Handler Plugins

