Skip to main content

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 update jest.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

Build docs developers (and LLMs) love