DEV Community

Cover image for Build Your Own Component Testing Library: A Developer's Guide
Aarav Joshi
Aarav Joshi

Posted on

Build Your Own Component Testing Library: A Developer's Guide

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

JavaScript has revolutionized web development, and component-based architectures have become the standard. Testing these components thoroughly requires specialized tools. I've spent years building testing libraries and want to share techniques for creating your own component testing solution.

DOM Rendering

The foundation of any component testing library is DOM rendering. When I first tackled this challenge, I realized the need for both isolated and integrated testing approaches.

Creating a lightweight DOM renderer starts with establishing an isolated environment. Virtual DOM implementations provide better performance than actual DOM manipulation.

function createRenderer() {
  const virtualDOM = document.createElement('div');

  return {
    render(component) {
      ReactDOM.render(component, virtualDOM);
      return virtualDOM;
    },

    shallowRender(component) {
      // Prevent children from rendering deeply
      const shallowContext = createShallowContext();
      ReactDOM.render(
        <ShallowRenderer context={shallowContext}>
          {component}
        </ShallowRenderer>,
        virtualDOM
      );
      return virtualDOM;
    },

    cleanup() {
      ReactDOM.unmountComponentAtNode(virtualDOM);
    }
  };
}

// Usage
const renderer = createRenderer();
const element = renderer.render(<Button>Click Me</Button>);
expect(element.textContent).toBe('Click Me');
renderer.cleanup();
Enter fullscreen mode Exit fullscreen mode

For shallow rendering, I've found that intercepting React's reconciliation process works best. This lets us substitute child components with placeholders, focusing tests on the component's specific behavior without testing its children.

function ShallowRenderer({ children, context }) {
  return React.cloneElement(children, {}, null);
}

function createShallowContext() {
  const componentStubs = new Map();

  return {
    registerComponent(Component, stub) {
      componentStubs.set(Component, stub);
    },

    getStub(Component) {
      return componentStubs.get(Component) || null;
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

Event Simulation

Accurate event simulation proved challenging due to browser differences. I learned to create normalized event objects that work across environments.

function createEventSimulator() {
  return {
    click(element, options = {}) {
      const clickEvent = new MouseEvent('click', {
        bubbles: true,
        cancelable: true,
        view: window,
        ...options
      });
      element.dispatchEvent(clickEvent);
    },

    change(element, value) {
      // Set the value
      element.value = value;

      // Trigger change event
      const event = new Event('change', {
        bubbles: true,
        cancelable: true
      });
      element.dispatchEvent(event);
    },

    keyPress(element, key, options = {}) {
      const keyEvent = new KeyboardEvent('keypress', {
        key,
        bubbles: true,
        cancelable: true,
        ...options
      });
      element.dispatchEvent(keyEvent);
    },

    drag(element, { from, to }) {
      // Simulate dragstart
      const dragStartEvent = new MouseEvent('dragstart', {
        bubbles: true,
        clientX: from.x,
        clientY: from.y
      });
      element.dispatchEvent(dragStartEvent);

      // Simulate dragover on target
      const dragOverEvent = new MouseEvent('dragover', {
        bubbles: true,
        clientX: to.x,
        clientY: to.y
      });
      element.dispatchEvent(dragOverEvent);

      // Simulate drop on target
      const dropEvent = new MouseEvent('drop', {
        bubbles: true,
        clientX: to.x,
        clientY: to.y
      });
      element.dispatchEvent(dropEvent);
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

Touch events require special handling to properly simulate multi-touch interactions:

function simulateTouch(element, touchType, touchPoints) {
  const touchEvent = new TouchEvent(touchType, {
    bubbles: true,
    cancelable: true,
    touches: createTouchList(touchPoints),
    targetTouches: createTouchList(touchPoints),
    changedTouches: createTouchList(touchPoints)
  });

  element.dispatchEvent(touchEvent);
}

function createTouchList(points) {
  return points.map((point, id) => new Touch({
    identifier: id,
    target: element,
    clientX: point.x,
    clientY: point.y
  }));
}

// Usage example
simulateTouch(element, 'touchstart', [
  { x: 100, y: 100 },
  { x: 200, y: 150 }
]);
Enter fullscreen mode Exit fullscreen mode

State Verification

Effective component testing requires validating both DOM output and internal state. I've developed specialized assertions for different component types.

const componentAssertions = {
  hasClass(element, className) {
    return element.classList.contains(className);
  },

  hasAttribute(element, name, value) {
    if (value === undefined) {
      return element.hasAttribute(name);
    }
    return element.getAttribute(name) === value;
  },

  hasStyle(element, property, value) {
    const style = window.getComputedStyle(element);
    return style[property] === value;
  },

  isVisible(element) {
    const style = window.getComputedStyle(element);
    return style.display !== 'none' && 
           style.visibility !== 'hidden' && 
           style.opacity !== '0';
  },

  containsText(element, text) {
    return element.textContent.includes(text);
  },

  matchesAccessibility(element, criteria) {
    // Check ARIA attributes
    if (criteria.role && element.getAttribute('role') !== criteria.role) {
      return false;
    }

    // Check for required ARIA attributes based on role
    if (criteria.required) {
      for (const attr of criteria.required) {
        if (!element.hasAttribute(`aria-${attr}`)) {
          return false;
        }
      }
    }

    return true;
  }
};
Enter fullscreen mode Exit fullscreen mode

For form components, I created specialized assertions:

const formAssertions = {
  hasValue(input, expected) {
    return input.value === expected;
  },

  isChecked(checkbox) {
    return checkbox.checked === true;
  },

  isDisabled(element) {
    return element.disabled === true;
  },

  hasError(formElement, errorMessage) {
    // Find associated error element
    const id = formElement.id;
    const errorElement = document.querySelector(`[aria-describedby="${id}"]`);
    return errorElement && errorElement.textContent.includes(errorMessage);
  },

  isValid(formElement) {
    return formElement.validity.valid;
  }
};
Enter fullscreen mode Exit fullscreen mode

Asynchronous Testing

Handling async component behavior has always been a challenge. I've created utilities that manage timing and state transitions.

async function waitFor(predicate, options = {}) {
  const { timeout = 1000, interval = 50 } = options;
  const startTime = Date.now();

  return new Promise((resolve, reject) => {
    const check = () => {
      try {
        const result = predicate();
        if (result) {
          resolve(result);
          return;
        }
      } catch (error) {
        // Predicate threw an error, keep waiting
      }

      const elapsed = Date.now() - startTime;
      if (elapsed >= timeout) {
        reject(new Error(`Timed out after ${timeout}ms waiting for predicate to be true`));
        return;
      }

      setTimeout(check, interval);
    };

    check();
  });
}

async function waitForElement(selector, container = document) {
  return waitFor(() => container.querySelector(selector));
}

async function waitForRender(component) {
  // Force a microtask to allow React to render
  await Promise.resolve();

  // For React 18+ with concurrent features
  if (typeof window.ReactDOM?.flushSync === 'function') {
    ReactDOM.flushSync();
  }

  return component;
}

async function waitForAnimation(element) {
  if (!element.getAnimations) {
    // Fallback for browsers without Animation API
    return waitFor(() => 
      !element.classList.contains('animating') && 
      !element.style.animation
    );
  }

  return waitFor(() => element.getAnimations().every(a => a.playState === 'finished'));
}
Enter fullscreen mode Exit fullscreen mode

Handling React state updates requires specialized techniques:

function act(callback) {
  // Use React's test utilities if available
  if (typeof React.act === 'function') {
    return React.act(callback);
  }

  // Fallback implementation
  callback();
  // Flush microtasks
  return Promise.resolve();
}

async function waitForUpdate(component, propName, expectedValue) {
  return waitFor(() => {
    const currentProps = component.props;
    return currentProps[propName] === expectedValue;
  });
}

// Example usage
await act(async () => {
  fireEvent.click(button);
  await waitForUpdate(component, 'isLoading', false);
});
Enter fullscreen mode Exit fullscreen mode

Snapshot Generation

Snapshot testing helps detect unintended changes. I've built tools that create serializable representations of components.

function createSnapshotter() {
  return {
    captureDOM(element) {
      return {
        tagName: element.tagName.toLowerCase(),
        attributes: getAttributes(element),
        children: Array.from(element.childNodes).map(node => {
          if (node.nodeType === Node.TEXT_NODE) {
            return { type: 'text', content: node.textContent };
          }
          return this.captureDOM(node);
        }),
        textContent: element.textContent
      };
    },

    captureComponent(component) {
      const renderer = createRenderer();
      const element = renderer.render(component);
      const snapshot = this.captureDOM(element);
      renderer.cleanup();
      return snapshot;
    },

    compare(snapshot1, snapshot2) {
      const differences = [];

      compareNodes(snapshot1, snapshot2, '', differences);

      return {
        hasDifferences: differences.length > 0,
        differences
      };
    }
  };
}

function getAttributes(element) {
  const attributes = {};
  Array.from(element.attributes).forEach(attr => {
    attributes[attr.name] = attr.value;
  });
  return attributes;
}

function compareNodes(node1, node2, path, differences) {
  if (!node1 || !node2) {
    differences.push({
      path,
      message: !node1 ? 'Node added' : 'Node removed'
    });
    return;
  }

  if (node1.type !== node2.type) {
    differences.push({
      path,
      message: `Node type changed from ${node1.type} to ${node2.type}`
    });
    return;
  }

  if (node1.type === 'text' && node1.content !== node2.content) {
    differences.push({
      path,
      message: `Text changed from "${node1.content}" to "${node2.content}"`
    });
    return;
  }

  if (node1.tagName !== node2.tagName) {
    differences.push({
      path,
      message: `Tag changed from ${node1.tagName} to ${node2.tagName}`
    });
  }

  // Compare attributes
  const allAttributes = new Set([
    ...Object.keys(node1.attributes || {}),
    ...Object.keys(node2.attributes || {})
  ]);

  for (const attr of allAttributes) {
    const val1 = node1.attributes?.[attr];
    const val2 = node2.attributes?.[attr];

    if (val1 !== val2) {
      differences.push({
        path: `${path}.attributes[${attr}]`,
        message: `Attribute "${attr}" changed from "${val1}" to "${val2}"`
      });
    }
  }

  // Compare children
  const children1 = node1.children || [];
  const children2 = node2.children || [];

  const maxLength = Math.max(children1.length, children2.length);
  for (let i = 0; i < maxLength; i++) {
    compareNodes(
      children1[i], 
      children2[i], 
      `${path}.children[${i}]`, 
      differences
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Mock Component Context

Components rarely exist in isolation. They depend on context providers for themes, state, routing, and more. I've developed approaches to mock these contexts.

function createContextMock(Context, defaultValue) {
  return {
    withValue(value, children) {
      return (
        <Context.Provider value={value}>
          {children}
        </Context.Provider>
      );
    },

    peek() {
      // Get current context value
      let currentValue = defaultValue;
      const Probe = () => {
        currentValue = React.useContext(Context);
        return null;
      };

      const renderer = createRenderer();
      renderer.render(<Probe />);
      renderer.cleanup();

      return currentValue;
    }
  };
}

// Mock theme context
const ThemeContext = React.createContext({ mode: 'light' });
const themeMock = createContextMock(ThemeContext, { mode: 'light' });

// Test with dark theme
const { container } = render(
  themeMock.withValue({ mode: 'dark' }, <Button>Submit</Button>)
);

expect(container.querySelector('.button')).toHaveClass('dark-theme');
Enter fullscreen mode Exit fullscreen mode

For more complex context like stores or routers:

function createStoreMock(initialState = {}) {
  let state = { ...initialState };
  const listeners = new Set();

  return {
    getState() {
      return { ...state };
    },

    setState(newState) {
      state = { ...state, ...newState };
      listeners.forEach(listener => listener(state));
    },

    subscribe(listener) {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },

    withStore(component) {
      return (
        <StoreContext.Provider value={this}>
          {component}
        </StoreContext.Provider>
      );
    }
  };
}

function createRouterMock(initialRoute = '/') {
  let currentRoute = initialRoute;
  const listeners = new Set();

  return {
    getCurrentRoute() {
      return currentRoute;
    },

    navigate(route) {
      currentRoute = route;
      listeners.forEach(listener => listener(currentRoute));
    },

    subscribe(listener) {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },

    withRouter(component) {
      return (
        <RouterContext.Provider 
          value={{ 
            route: currentRoute, 
            navigate: this.navigate.bind(this) 
          }}
        >
          {component}
        </RouterContext.Provider>
      );
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

Putting it all together, I've created a comprehensive testing utility:

function createTestBed() {
  const container = document.createElement('div');
  document.body.appendChild(container);

  const events = createEventSimulator();
  const assertions = { ...componentAssertions, ...formAssertions };
  const snapshotter = createSnapshotter();

  return {
    render(component) {
      ReactDOM.render(component, container);

      return {
        container,

        // Selection methods
        getByTestId: (id) => container.querySelector(`[data-testid="${id}"]`),
        getByText: (text) => [...container.querySelectorAll('*')]
          .find(el => el.textContent.trim() === text),
        queryBySelector: (selector) => container.querySelector(selector),

        // Event methods
        fireEvent: events,

        // Assertion methods
        assert: assertions,

        // Async methods
        waitFor,
        waitForElement: (selector) => waitForElement(selector, container),

        // Snapshot methods
        snapshot: () => snapshotter.captureDOM(container),

        // Cleanup
        unmount: () => {
          ReactDOM.unmountComponentAtNode(container);
        }
      };
    },

    cleanup() {
      document.body.removeChild(container);
    },

    // Context mocking
    createContextMock,
    createStoreMock,
    createRouterMock
  };
}

// Example usage
const testBed = createTestBed();

const { getByTestId, fireEvent, assert, waitFor, unmount } = testBed.render(
  <Counter initialCount={0} />
);

const countDisplay = getByTestId('count-display');
const incrementButton = getByTestId('increment-button');

expect(assert.containsText(countDisplay, '0')).toBe(true);

fireEvent.click(incrementButton);

waitFor(() => assert.containsText(countDisplay, '1'))
  .then(() => {
    console.log('Test passed!');
    unmount();
    testBed.cleanup();
  });
Enter fullscreen mode Exit fullscreen mode

Building a component testing library has deepened my understanding of JavaScript and browser behavior. The techniques I've shared allow for comprehensive component testing, from simple DOM rendering to complex asynchronous behavior and context mocking.

These patterns form the foundation of many popular testing libraries today. By understanding how they work under the hood, you gain flexibility to customize testing solutions for your specific needs.

Remember that testing is about confidence, not coverage. The best testing library is one that helps you write meaningful tests with minimal friction. The techniques I've shared allow you to build such a library, tailored to your specific testing philosophy and component architecture.


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)