CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/npm-testing-library--react-hooks

Simple and complete React hooks testing utilities that encourage good testing practices.

Pending
Overview
Eval results
Files

async-testing.mddocs/

Async Testing

Utilities for testing asynchronous hook behavior, including waiting for updates, value changes, and condition fulfillment. These utilities are essential for testing hooks that perform async operations or have delayed effects.

Capabilities

waitFor

Waits for a condition to become true, with configurable timeout and polling interval. Useful for testing hooks that have async side effects or delayed updates.

/**
 * Wait for a condition to become true
 * @param callback - Function that returns true when condition is met, or void (treated as true)
 * @param options - Configuration for timeout and polling interval
 * @returns Promise that resolves when condition is met
 * @throws TimeoutError if condition is not met within timeout
 */
waitFor(callback: () => boolean | void, options?: WaitForOptions): Promise<void>;

interface WaitForOptions {
  /** Polling interval in milliseconds (default: 50ms, false disables polling) */
  interval?: number | false;
  /** Timeout in milliseconds (default: 1000ms, false disables timeout) */
  timeout?: number | false;
}

Usage Examples:

import { renderHook } from "@testing-library/react-hooks";
import { useState, useEffect } from "react";

function useAsyncData(url: string) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    setLoading(true);
    setError(null);
    
    fetch(url)
      .then(response => response.json())
      .then(data => {
        setData(data);
        setLoading(false);
      })
      .catch(error => {
        setError(error);
        setLoading(false);
      });
  }, [url]);
  
  return { data, loading, error };
}

test("async data loading", async () => {
  // Mock fetch
  global.fetch = jest.fn().mockResolvedValue({
    json: () => Promise.resolve({ id: 1, name: "Test Data" })
  });
  
  const { result, waitFor } = renderHook(() => useAsyncData("/api/data"));
  
  expect(result.current.loading).toBe(true);
  expect(result.current.data).toBe(null);
  
  // Wait for loading to complete
  await waitFor(() => !result.current.loading);
  
  expect(result.current.loading).toBe(false);
  expect(result.current.data).toEqual({ id: 1, name: "Test Data" });
  expect(result.current.error).toBe(null);
});

// Wait for specific condition
test("wait for specific value", async () => {
  const { result, waitFor } = renderHook(() => useAsyncData("/api/data"));
  
  // Wait for data to be loaded
  await waitFor(() => result.current.data !== null);
  
  expect(result.current.data).toBeTruthy();
});

// Custom timeout and interval
test("custom wait options", async () => {
  const { result, waitFor } = renderHook(() => useAsyncData("/api/slow"));
  
  // Wait with custom timeout and interval
  await waitFor(
    () => !result.current.loading,
    { timeout: 5000, interval: 100 }
  );
  
  expect(result.current.loading).toBe(false);
});

waitForValueToChange

Waits for a selected value to change from its current value. Useful when you want to wait for a specific piece of state to update.

/**
 * Wait for a selected value to change from its current value
 * @param selector - Function that returns the value to monitor
 * @param options - Configuration for timeout and polling interval
 * @returns Promise that resolves when the value changes
 * @throws TimeoutError if value doesn't change within timeout
 */
waitForValueToChange(
  selector: () => unknown,
  options?: WaitForValueToChangeOptions
): Promise<void>;

interface WaitForValueToChangeOptions {
  /** Polling interval in milliseconds (default: 50ms, false disables polling) */
  interval?: number | false;
  /** Timeout in milliseconds (default: 1000ms, false disables timeout) */
  timeout?: number | false;
}

Usage Examples:

function useCounter() {
  const [count, setCount] = useState(0);
  
  const incrementAsync = async () => {
    await new Promise(resolve => setTimeout(resolve, 100));
    setCount(prev => prev + 1);
  };
  
  return { count, incrementAsync };
}

test("wait for value to change", async () => {
  const { result, waitForValueToChange } = renderHook(() => useCounter());
  
  expect(result.current.count).toBe(0);
  
  // Start async operation
  result.current.incrementAsync();
  
  // Wait for count to change
  await waitForValueToChange(() => result.current.count);
  
  expect(result.current.count).toBe(1);
});

// Wait for nested property changes
function useUserProfile(userId: string) {
  const [profile, setProfile] = useState({ name: "", email: "", loading: true });
  
  useEffect(() => {
    fetchUserProfile(userId).then(data => {
      setProfile({ ...data, loading: false });
    });
  }, [userId]);
  
  return profile;
}

test("wait for nested property change", async () => {
  const { result, waitForValueToChange } = renderHook(() => 
    useUserProfile("123")
  );
  
  expect(result.current.loading).toBe(true);
  
  // Wait for loading state to change
  await waitForValueToChange(() => result.current.loading);
  
  expect(result.current.loading).toBe(false);
  expect(result.current.name).toBeTruthy();
});

waitForNextUpdate

Waits for the next hook update/re-render, regardless of what causes it. This is useful when you know an update will happen but don't know exactly what will change.

/**
 * Wait for the next hook update/re-render
 * @param options - Configuration for timeout
 * @returns Promise that resolves on the next update
 * @throws TimeoutError if no update occurs within timeout
 */
waitForNextUpdate(options?: WaitForNextUpdateOptions): Promise<void>;

interface WaitForNextUpdateOptions {
  /** Timeout in milliseconds (default: 1000ms, false disables timeout) */
  timeout?: number | false;
}

Usage Examples:

function useWebSocket(url: string) {
  const [data, setData] = useState(null);
  const [connected, setConnected] = useState(false);
  
  useEffect(() => {
    const ws = new WebSocket(url);
    
    ws.onopen = () => setConnected(true);
    ws.onmessage = (event) => setData(JSON.parse(event.data));
    ws.onclose = () => setConnected(false);
    
    return () => ws.close();
  }, [url]);
  
  return { data, connected };
}

test("websocket updates", async () => {
  const mockWebSocket = {
    onopen: null,
    onmessage: null,
    onclose: null,
    close: jest.fn()
  };
  
  global.WebSocket = jest.fn(() => mockWebSocket);
  
  const { result, waitForNextUpdate } = renderHook(() => 
    useWebSocket("ws://localhost:8080")
  );
  
  expect(result.current.connected).toBe(false);
  
  // Simulate connection
  mockWebSocket.onopen();
  
  // Wait for next update (connection state change)
  await waitForNextUpdate();
  
  expect(result.current.connected).toBe(true);
  
  // Simulate message
  mockWebSocket.onmessage({ data: JSON.stringify({ message: "Hello" }) });
  
  // Wait for next update (data change)
  await waitForNextUpdate();
  
  expect(result.current.data).toEqual({ message: "Hello" });
});

// With custom timeout
test("wait for update with timeout", async () => {
  const { waitForNextUpdate } = renderHook(() => useWebSocket("ws://test"));
  
  // Wait with longer timeout
  await waitForNextUpdate({ timeout: 2000 });
});

Error Handling in Async Utils

All async utilities can throw TimeoutError when operations don't complete within the specified timeout:

import { renderHook } from "@testing-library/react-hooks";

function useNeverUpdating() {
  const [value] = useState("static");
  return value;
}

test("timeout error handling", async () => {
  const { result, waitForValueToChange } = renderHook(() => useNeverUpdating());
  
  // This will throw TimeoutError after 100ms
  await expect(
    waitForValueToChange(() => result.current, { timeout: 100 })
  ).rejects.toThrow("Timed out");
});

test("no timeout with false", async () => {
  const { result, waitFor } = renderHook(() => useNeverUpdating());
  
  // This would wait forever, but we'll resolve it manually
  const waitPromise = waitFor(() => false, { timeout: false });
  
  // In real tests, you'd trigger the condition to become true
  // For this example, we'll just verify the promise doesn't reject immediately
  const timeoutPromise = new Promise((_, reject) => 
    setTimeout(() => reject(new Error("Should not timeout")), 100)
  );
  
  // The wait should not timeout
  await expect(Promise.race([waitPromise, timeoutPromise])).rejects.toThrow("Should not timeout");
});

Combining Async Utilities

You can combine multiple async utilities for complex testing scenarios:

function useComplexAsync() {
  const [step, setStep] = useState(1);
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    if (step === 1) {
      setTimeout(() => setStep(2), 100);
    } else if (step === 2) {
      fetchData()
        .then(setData)
        .catch(setError)
        .finally(() => setStep(3));
    }
  }, [step]);
  
  return { step, data, error };
}

test("complex async flow", async () => {
  const { result, waitFor, waitForValueToChange, waitForNextUpdate } = 
    renderHook(() => useComplexAsync());
  
  expect(result.current.step).toBe(1);
  
  // Wait for step to change to 2
  await waitForValueToChange(() => result.current.step);
  expect(result.current.step).toBe(2);
  
  // Wait for data or error
  await waitFor(() => 
    result.current.data !== null || result.current.error !== null
  );
  
  // Wait for final step
  await waitForValueToChange(() => result.current.step);
  expect(result.current.step).toBe(3);
});

Types

class TimeoutError extends Error {
  constructor(util: Function, timeout: number);
}

Install with Tessl CLI

npx tessl i tessl/npm-testing-library--react-hooks

docs

act-utilities.md

async-testing.md

cleanup-management.md

error-handling.md

hook-rendering.md

index.md

server-side-rendering.md

tile.json