GlyphUI applications can be tested using standard JavaScript testing frameworks. The framework is designed to be testable, with clear component APIs and predictable behavior.
Testing Setup
GlyphUI uses Vitest as its test runner, configured with jsdom for DOM environment simulation.
Vitest Configuration
From vitest.config.js:
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
reporters: "verbose",
environment: "jsdom"
}
});
Installing Test Dependencies
npm install --save-dev vitest jsdom @vitest/ui
Package.json Scripts
Add test scripts to your package.json:
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
}
}
Basic Component Testing
Testing a Simple Component
import { expect, test, beforeEach, afterEach } from "vitest";
import { Component, h } from "glyphui";
class Counter extends Component {
constructor(props) {
super(props, {
initialState: { count: props.initialCount || 0 }
});
}
increment() {
this.setState({ count: this.state.count + 1 });
}
render(props, state) {
return h('div', {}, [
h('span', { id: 'count' }, [state.count.toString()]),
h('button', {
id: 'increment',
on: { click: () => this.increment() }
}, ['Increment'])
]);
}
}
test('Counter increments when button is clicked', () => {
// Create container
const container = document.createElement('div');
document.body.appendChild(container);
// Mount component
const counter = new Counter({ initialCount: 5 });
counter.mount(container);
// Check initial state
const countEl = container.querySelector('#count');
expect(countEl.textContent).toBe('5');
// Click button
const button = container.querySelector('#increment');
button.click();
// Check updated state
expect(countEl.textContent).toBe('6');
// Cleanup
counter.unmount();
document.body.removeChild(container);
});
Test Helper Functions
Create reusable test helpers:
// test-utils.js
export function createTestContainer() {
const container = document.createElement('div');
document.body.appendChild(container);
return container;
}
export function cleanupTestContainer(container) {
if (container && container.parentNode) {
container.parentNode.removeChild(container);
}
}
export function mountComponent(ComponentClass, props = {}) {
const container = createTestContainer();
const instance = new ComponentClass(props);
instance.mount(container);
return { instance, container };
}
export function unmountComponent(instance, container) {
if (instance) instance.unmount();
cleanupTestContainer(container);
}
Using the helpers:
import { expect, test } from "vitest";
import { mountComponent, unmountComponent } from './test-utils';
test('Counter with helpers', () => {
const { instance, container } = mountComponent(Counter, { initialCount: 0 });
const button = container.querySelector('#increment');
button.click();
const count = container.querySelector('#count');
expect(count.textContent).toBe('1');
unmountComponent(instance, container);
});
Testing Component Lifecycle
import { expect, test, vi } from "vitest";
import { Component, h } from "glyphui";
class LifecycleComponent extends Component {
constructor(props) {
super(props, { initialState: { mounted: false } });
this.onMountCallback = props.onMount;
this.onUnmountCallback = props.onUnmount;
}
mounted() {
this.setState({ mounted: true });
if (this.onMountCallback) {
this.onMountCallback();
}
}
beforeUnmount() {
if (this.onUnmountCallback) {
this.onUnmountCallback();
}
}
render(props, state) {
return h('div', {}, [state.mounted ? 'Mounted' : 'Not Mounted']);
}
}
test('Component lifecycle hooks are called', () => {
const onMount = vi.fn();
const onUnmount = vi.fn();
const container = document.createElement('div');
document.body.appendChild(container);
const component = new LifecycleComponent({
onMount,
onUnmount
});
component.mount(container);
// Wait for mounted() to be called
setTimeout(() => {
expect(onMount).toHaveBeenCalledTimes(1);
expect(container.textContent).toBe('Mounted');
component.unmount();
expect(onUnmount).toHaveBeenCalledTimes(1);
document.body.removeChild(container);
}, 0);
});
Testing State Updates
import { expect, test } from "vitest";
import { Component, h } from "glyphui";
class TodoList extends Component {
constructor(props) {
super(props, {
initialState: { todos: [], input: '' }
});
}
addTodo() {
if (this.state.input.trim()) {
this.setState({
todos: [...this.state.todos, {
id: Date.now(),
text: this.state.input
}],
input: ''
});
}
}
updateInput(value) {
this.setState({ input: value });
}
render(props, state) {
return h('div', {}, [
h('input', {
id: 'todo-input',
value: state.input,
on: { input: (e) => this.updateInput(e.target.value) }
}),
h('button', {
id: 'add-todo',
on: { click: () => this.addTodo() }
}, ['Add']),
h('ul', {},
state.todos.map(todo =>
h('li', { key: todo.id }, [todo.text])
)
)
]);
}
}
test('TodoList adds todos', () => {
const container = document.createElement('div');
document.body.appendChild(container);
const todoList = new TodoList({});
todoList.mount(container);
// Type in input
const input = container.querySelector('#todo-input');
input.value = 'Buy milk';
input.dispatchEvent(new Event('input'));
// Click add button
const button = container.querySelector('#add-todo');
button.click();
// Check todo was added
const todos = container.querySelectorAll('li');
expect(todos.length).toBe(1);
expect(todos[0].textContent).toBe('Buy milk');
// Input should be cleared
expect(input.value).toBe('');
// Cleanup
todoList.unmount();
document.body.removeChild(container);
});
Testing Hooks
Testing useState
import { expect, test } from "vitest";
import { useState, h, initHooks, finishHooks } from "glyphui";
function Counter() {
const [count, setCount] = useState(0);
return h('div', {}, [
h('span', { id: 'count' }, [count.toString()]),
h('button', {
id: 'increment',
on: { click: () => setCount(count + 1) }
}, ['Increment'])
]);
}
test('useState hook updates state', () => {
const container = document.createElement('div');
document.body.appendChild(container);
// Create a functional component wrapper
class FunctionalWrapper extends Component {
render() {
initHooks(this);
const vdom = Counter();
finishHooks();
return vdom;
}
}
const wrapper = new FunctionalWrapper({});
wrapper.mount(container);
const countEl = container.querySelector('#count');
expect(countEl.textContent).toBe('0');
const button = container.querySelector('#increment');
button.click();
setTimeout(() => {
expect(countEl.textContent).toBe('1');
wrapper.unmount();
document.body.removeChild(container);
}, 0);
});
Testing useEffect
import { expect, test, vi } from "vitest";
import { useState, useEffect } from "glyphui";
function DataFetcher({ url }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch(url)
.then(res => res.json())
.then(data => {
setData(data);
setLoading(false);
});
}, [url]);
if (loading) {
return h('div', {}, ['Loading...']);
}
return h('div', {}, [JSON.stringify(data)]);
}
test('useEffect fetches data', async () => {
// Mock fetch
global.fetch = vi.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ message: 'Hello' })
})
);
const container = document.createElement('div');
document.body.appendChild(container);
class Wrapper extends Component {
render() {
initHooks(this);
const vdom = DataFetcher({ url: '/api/data' });
finishHooks();
return vdom;
}
}
const wrapper = new Wrapper({});
wrapper.mount(container);
// Initially loading
expect(container.textContent).toBe('Loading...');
// Wait for effect and data to load
await new Promise(resolve => setTimeout(resolve, 10));
expect(global.fetch).toHaveBeenCalledWith('/api/data');
expect(container.textContent).toContain('Hello');
wrapper.unmount();
document.body.removeChild(container);
});
Testing Lazy Loading
import { expect, test } from "vitest";
import { lazy, createDelayedComponent, Component, h } from "glyphui";
class LazyComponent extends Component {
render() {
return h('div', { id: 'lazy' }, ['Lazy Loaded!']);
}
}
test('lazy component loads after delay', async () => {
const LazyLoaded = lazy(
createDelayedComponent(LazyComponent, 100)
);
const container = document.createElement('div');
document.body.appendChild(container);
class Wrapper extends Component {
render() {
return LazyLoaded();
}
}
const wrapper = new Wrapper({});
wrapper.mount(container);
// Initially shows loading
expect(container.textContent).toContain('Loading');
// Wait for component to load
await new Promise(resolve => setTimeout(resolve, 150));
// Should show lazy component
expect(container.querySelector('#lazy')).toBeTruthy();
expect(container.textContent).toBe('Lazy Loaded!');
wrapper.unmount();
document.body.removeChild(container);
});
Testing Event Handlers
import { expect, test, vi } from "vitest";
import { Component, h } from "glyphui";
class Button extends Component {
render(props) {
return h('button', {
id: 'test-button',
on: { click: props.onClick }
}, [props.label]);
}
}
test('Button calls onClick handler', () => {
const onClick = vi.fn();
const container = document.createElement('div');
document.body.appendChild(container);
const button = new Button({ onClick, label: 'Click Me' });
button.mount(container);
const buttonEl = container.querySelector('#test-button');
buttonEl.click();
expect(onClick).toHaveBeenCalledTimes(1);
button.unmount();
document.body.removeChild(container);
});
Snapshot Testing
import { expect, test } from "vitest";
import { Component, h } from "glyphui";
class Card extends Component {
render(props) {
return h('div', { class: 'card' }, [
h('h2', {}, [props.title]),
h('p', {}, [props.content])
]);
}
}
test('Card renders correctly', () => {
const container = document.createElement('div');
document.body.appendChild(container);
const card = new Card({
title: 'Test Card',
content: 'This is test content'
});
card.mount(container);
expect(container.innerHTML).toMatchSnapshot();
card.unmount();
document.body.removeChild(container);
});
Best Practices
- Isolate tests: Each test should be independent and not rely on other tests
- Clean up: Always unmount components and remove containers after tests
- Use test utilities: Create helper functions for common testing patterns
- Mock external dependencies: Use
vi.fn() to mock functions and APIs
- Test user interactions: Simulate real user behavior (clicks, inputs, etc.)
- Test edge cases: Include tests for error states and boundary conditions
- Keep tests simple: Each test should verify one specific behavior
- Use descriptive names: Test names should clearly describe what they verify
Organize your tests in the same directory structure as your components. This makes it easy to find and maintain tests.
Running Tests
# Run all tests
npm test
# Run tests in watch mode
npm test -- --watch
# Run tests with UI
npm run test:ui
# Run tests with coverage
npm run test:coverage
# Run specific test file
npm test -- Counter.test.js
Testing Checklist
When testing components, verify:
Always clean up after tests to prevent memory leaks and test interference. Unmount components and remove DOM elements.