CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/npm-testing-library--react-native

Simple and complete React Native testing utilities that encourage good testing practices

Pending
Overview
Eval results
Files

hooks.mddocs/

Hook Testing

Specialized utilities for testing React hooks in isolation with proper act wrapping, lifecycle management, and both synchronous and asynchronous rendering support.

Capabilities

RenderHook Function

Test React hooks in isolation without needing to create wrapper components.

/**
 * Render a hook for testing in isolation
 * @param hook - Hook function to test
 * @param options - Hook rendering options
 * @returns RenderHookResult with hook result and utilities
 */
function renderHook<Result, Props>(
  hook: (props: Props) => Result,
  options?: RenderHookOptions<Props>
): RenderHookResult<Result, Props>;

interface RenderHookOptions<Props> {
  /** Initial props to pass to the hook */
  initialProps?: Props;
  
  /** React component wrapper (for providers) */
  wrapper?: React.ComponentType<any>;
  
  /** Enable/disable concurrent rendering */
  concurrentRoot?: boolean;
}

interface RenderHookResult<Result, Props> {
  /** Current hook result in a ref object */
  result: { current: Result };
  
  /** Re-render hook with new props */
  rerender: (props?: Props) => void;
  
  /** Unmount the hook */
  unmount: () => void;
}

Usage Examples:

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

test("testing useState hook", () => {
  const useCounter = (initialValue = 0) => {
    const [count, setCount] = useState(initialValue);
    
    const increment = () => setCount(prev => prev + 1);
    const decrement = () => setCount(prev => prev - 1);
    const reset = () => setCount(initialValue);
    
    return { count, increment, decrement, reset };
  };

  const { result } = renderHook(useCounter, {
    initialProps: 5
  });

  // Initial state
  expect(result.current.count).toBe(5);

  // Test increment
  act(() => {
    result.current.increment();
  });
  expect(result.current.count).toBe(6);

  // Test decrement
  act(() => {
    result.current.decrement();
  });
  expect(result.current.count).toBe(5);

  // Test reset
  act(() => {
    result.current.reset();
  });
  expect(result.current.count).toBe(5);
});

test("testing useEffect hook", () => {
  const useDocumentTitle = (title) => {
    const [currentTitle, setCurrentTitle] = useState(title);
    
    useEffect(() => {
      setCurrentTitle(title);
    }, [title]);
    
    return currentTitle;
  };

  const { result, rerender } = renderHook(useDocumentTitle, {
    initialProps: "Initial Title"
  });

  // Initial title
  expect(result.current).toBe("Initial Title");

  // Update title
  rerender("Updated Title");
  expect(result.current).toBe("Updated Title");
});

Async Hook Testing

Test hooks with asynchronous behavior using renderHookAsync and proper async handling.

/**
 * Async version of renderHook for testing hooks with async behavior
 * @param hook - Hook function to test
 * @param options - Async hook rendering options
 * @returns Promise resolving to RenderHookAsyncResult
 */
function renderHookAsync<Result, Props>(
  hook: (props: Props) => Result,
  options?: RenderHookOptions<Props>
): Promise<RenderHookAsyncResult<Result, Props>>;

interface RenderHookAsyncResult<Result, Props> {
  /** Current hook result in a ref object */
  result: { current: Result };
  
  /** Re-render hook with new props asynchronously */
  rerenderAsync: (props?: Props) => Promise<void>;
  
  /** Unmount the hook asynchronously */
  unmountAsync: () => Promise<void>;
}

Usage Examples:

test("async hook testing", async () => {
  const useAsyncData = (url) => {
    const [data, setData] = useState(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(null);

    useEffect(() => {
      const fetchData = async () => {
        try {
          setLoading(true);
          setError(null);
          
          // Simulate API call
          await new Promise(resolve => setTimeout(resolve, 100));
          
          if (url === "/error") {
            throw new Error("API Error");
          }
          
          setData(`Data from ${url}`);
        } catch (err) {
          setError(err.message);
        } finally {
          setLoading(false);
        }
      };

      fetchData();
    }, [url]);

    return { data, loading, error };
  };

  const { result } = await renderHookAsync(useAsyncData, {
    initialProps: "/api/users"
  });

  // Initially loading
  expect(result.current.loading).toBe(true);
  expect(result.current.data).toBeNull();
  expect(result.current.error).toBeNull();

  // Wait for data to load
  await waitFor(() => {
    expect(result.current.loading).toBe(false);
  });

  expect(result.current.data).toBe("Data from /api/users");
  expect(result.current.error).toBeNull();
});

test("async hook error handling", async () => {
  const useAsyncData = (url) => {
    const [data, setData] = useState(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(null);

    useEffect(() => {
      const fetchData = async () => {
        try {
          setLoading(true);
          setError(null);
          await new Promise(resolve => setTimeout(resolve, 50));
          
          if (url === "/error") {
            throw new Error("Network error");
          }
          
          setData(`Success: ${url}`);
        } catch (err) {
          setError(err.message);
        } finally {
          setLoading(false);
        }
      };

      fetchData();
    }, [url]);

    return { data, loading, error };
  };

  const { result, rerenderAsync } = await renderHookAsync(useAsyncData, {
    initialProps: "/api/success"
  });

  // Wait for successful load
  await waitFor(() => {
    expect(result.current.loading).toBe(false);
  });
  
  expect(result.current.data).toBe("Success: /api/success");
  expect(result.current.error).toBeNull();

  // Test error case
  await rerenderAsync("/error");
  
  await waitFor(() => {
    expect(result.current.loading).toBe(false);
  });
  
  expect(result.current.data).toBeNull();
  expect(result.current.error).toBe("Network error");
});

Hook Testing with Context

Test hooks that depend on React context by providing wrapper components.

/**
 * Wrapper component type for providing context to hooks
 */
type WrapperComponent<Props = {}> = React.ComponentType<Props & { children: React.ReactNode }>;

Usage Examples:

// Context setup
const ThemeContext = React.createContext({ 
  theme: "light", 
  toggleTheme: () => {} 
});

const ThemeProvider = ({ children, initialTheme = "light" }) => {
  const [theme, setTheme] = useState(initialTheme);
  
  const toggleTheme = () => {
    setTheme(prev => prev === "light" ? "dark" : "light");
  };
  
  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
};

// Hook that uses context
const useTheme = () => {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error("useTheme must be used within ThemeProvider");
  }
  return context;
};

test("hook with context provider", () => {
  const { result } = renderHook(useTheme, {
    wrapper: ({ children }) => (
      <ThemeProvider initialTheme="dark">
        {children}
      </ThemeProvider>
    )
  });

  // Initial theme from provider
  expect(result.current.theme).toBe("dark");

  // Test theme toggle
  act(() => {
    result.current.toggleTheme();
  });

  expect(result.current.theme).toBe("light");

  // Toggle again
  act(() => {
    result.current.toggleTheme();
  });

  expect(result.current.theme).toBe("dark");
});

test("hook without context throws error", () => {
  expect(() => {
    renderHook(useTheme); // No wrapper provided
  }).toThrow("useTheme must be used within ThemeProvider");
});

test("hook with multiple providers", () => {
  const UserContext = React.createContext({ user: null });
  const UserProvider = ({ children, user }) => (
    <UserContext.Provider value={{ user }}>
      {children}
    </UserContext.Provider>
  );

  const useUserTheme = () => {
    const { theme } = useTheme();
    const { user } = useContext(UserContext);
    
    return {
      theme,
      userTheme: user?.preferredTheme || theme,
      user
    };
  };

  const MultiProvider = ({ children }) => (
    <ThemeProvider initialTheme="light">
      <UserProvider user={{ name: "John", preferredTheme: "dark" }}>
        {children}
      </UserProvider>
    </ThemeProvider>
  );

  const { result } = renderHook(useUserTheme, {
    wrapper: MultiProvider
  });

  expect(result.current.theme).toBe("light");
  expect(result.current.userTheme).toBe("dark");
  expect(result.current.user.name).toBe("John");
});

Custom Hook Testing Patterns

Advanced patterns for testing complex custom hooks.

/**
 * Test utilities for complex hook scenarios
 */

Usage Examples:

test("custom hook with dependencies", () => {
  const useDebounce = (value, delay) => {
    const [debouncedValue, setDebouncedValue] = useState(value);

    useEffect(() => {
      const handler = setTimeout(() => {
        setDebouncedValue(value);
      }, delay);

      return () => {
        clearTimeout(handler);
      };
    }, [value, delay]);

    return debouncedValue;
  };

  const { result, rerender } = renderHook(useDebounce, {
    initialProps: { value: "initial", delay: 500 }
  });

  // Initial value is set immediately
  expect(result.current).toBe("initial");

  // Change value
  rerender({ value: "updated", delay: 500 });

  // Value should still be "initial" until debounce delay
  expect(result.current).toBe("initial");

  // Fast-forward time
  act(() => {
    jest.advanceTimersByTime(500);
  });

  // Now value should be updated
  expect(result.current).toBe("updated");

  // Change delay and value again
  rerender({ value: "final", delay: 200 });
  expect(result.current).toBe("updated"); // Still old value

  act(() => {
    jest.advanceTimersByTime(200);
  });

  expect(result.current).toBe("final");
});

test("hook with cleanup", () => {
  const useEventListener = (eventName, handler) => {
    const savedHandler = useRef(handler);
    
    useEffect(() => {
      savedHandler.current = handler;
    }, [handler]);

    useEffect(() => {
      const eventListener = (event) => savedHandler.current(event);
      
      // Add event listener (mocked for testing)
      window.addEventListener?.(eventName, eventListener);
      
      return () => {
        window.removeEventListener?.(eventName, eventListener);
      };
    }, [eventName]);
  };

  const mockAddEventListener = jest.spyOn(window, 'addEventListener').mockImplementation();
  const mockRemoveEventListener = jest.spyOn(window, 'removeEventListener').mockImplementation();
  
  const handler = jest.fn();

  const { unmount, rerender } = renderHook(useEventListener, {
    initialProps: { eventName: "resize", handler }
  });

  // Event listener should be added
  expect(mockAddEventListener).toHaveBeenCalledWith("resize", expect.any(Function));

  // Change event name
  rerender({ eventName: "scroll", handler });

  // Should remove old listener and add new one
  expect(mockRemoveEventListener).toHaveBeenCalledWith("resize", expect.any(Function));
  expect(mockAddEventListener).toHaveBeenCalledWith("scroll", expect.any(Function));

  // Unmount should cleanup
  unmount();

  expect(mockRemoveEventListener).toHaveBeenCalledWith("scroll", expect.any(Function));

  mockAddEventListener.mockRestore();
  mockRemoveEventListener.mockRestore();
});

test("hook with reducer pattern", () => {
  const useCounter = () => {
    const [state, dispatch] = useReducer((state, action) => {
      switch (action.type) {
        case "increment":
          return { count: state.count + (action.payload || 1) };
        case "decrement":
          return { count: state.count - (action.payload || 1) };
        case "reset":
          return { count: action.payload || 0 };
        default:
          return state;
      }
    }, { count: 0 });

    const increment = (amount) => dispatch({ type: "increment", payload: amount });
    const decrement = (amount) => dispatch({ type: "decrement", payload: amount });
    const reset = (value) => dispatch({ type: "reset", payload: value });

    return {
      count: state.count,
      increment,
      decrement,
      reset
    };
  };

  const { result } = renderHook(useCounter);

  // Initial state
  expect(result.current.count).toBe(0);

  // Increment by 1 (default)
  act(() => {
    result.current.increment();
  });
  expect(result.current.count).toBe(1);

  // Increment by custom amount
  act(() => {
    result.current.increment(5);
  });
  expect(result.current.count).toBe(6);

  // Decrement
  act(() => {
    result.current.decrement(2);
  });
  expect(result.current.count).toBe(4);

  // Reset to custom value
  act(() => {
    result.current.reset(10);
  });
  expect(result.current.count).toBe(10);

  // Reset to default (0)
  act(() => {
    result.current.reset();
  });
  expect(result.current.count).toBe(0);
});

Hook Testing Best Practices

Guidelines and patterns for effective hook testing.

/**
 * Best practices for hook testing
 */

Usage Examples:

test("testing hook with external dependencies", () => {
  // Mock external dependencies
  const mockFetch = jest.fn();
  global.fetch = mockFetch;

  const useApi = (url) => {
    const [data, setData] = useState(null);
    const [loading, setLoading] = useState(false);
    const [error, setError] = useState(null);

    const fetchData = useCallback(async () => {
      setLoading(true);
      setError(null);
      
      try {
        const response = await fetch(url);
        const result = await response.json();
        setData(result);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    }, [url]);

    useEffect(() => {
      fetchData();
    }, [fetchData]);

    return { data, loading, error, refetch: fetchData };
  };

  // Mock successful response
  mockFetch.mockResolvedValueOnce({
    json: () => Promise.resolve({ id: 1, name: "Test" })
  });

  const { result } = renderHook(useApi, {
    initialProps: "/api/test"
  });

  expect(result.current.loading).toBe(true);

  // Wait for fetch to complete
  return waitFor(() => {
    expect(result.current.loading).toBe(false);
    expect(result.current.data).toEqual({ id: 1, name: "Test" });
    expect(result.current.error).toBeNull();
  });
});

test("testing hook error boundaries", () => {
  const useRiskyHook = (shouldThrow) => {
    const [value, setValue] = useState("safe");
    
    useEffect(() => {
      if (shouldThrow) {
        throw new Error("Hook error");
      }
    }, [shouldThrow]);
    
    return value;
  };

  // Test safe usage
  const { result, rerender } = renderHook(useRiskyHook, {
    initialProps: false
  });

  expect(result.current).toBe("safe");

  // Test error case - should be caught by error boundary
  expect(() => {
    rerender(true);
  }).toThrow("Hook error");
});

test("testing hook performance", () => {
  const useExpensiveCalculation = (input) => {
    const expensiveFunction = useCallback((n) => {
      // Simulate expensive calculation
      let result = 0;
      for (let i = 0; i < n; i++) {
        result += i;
      }
      return result;
    }, []);

    const memoizedResult = useMemo(() => {
      return expensiveFunction(input);
    }, [input, expensiveFunction]);

    return memoizedResult;
  };

  const expensiveFunctionSpy = jest.fn((n) => {
    let result = 0;
    for (let i = 0; i < n; i++) {
      result += i;
    }
    return result;
  });

  // Test with memoization
  const { result, rerender } = renderHook(useExpensiveCalculation, {
    initialProps: 1000
  });

  const firstResult = result.current;
  expect(typeof firstResult).toBe("number");

  // Re-render with same props - should not recalculate
  rerender(1000);
  expect(result.current).toBe(firstResult);

  // Re-render with different props - should recalculate
  rerender(2000);
  expect(result.current).not.toBe(firstResult);
});

Concurrent Mode and Suspense

Testing hooks in concurrent mode and with Suspense boundaries.

/**
 * Hook testing with concurrent features
 */

Usage Examples:

test("hook with concurrent rendering", async () => {
  const useConcurrentData = (query) => {
    const [data, setData] = useState(null);
    
    // Use transition for non-urgent updates
    const [isPending, startTransition] = useTransition();
    
    const updateData = (newQuery) => {
      startTransition(() => {
        // Expensive state update
        setData(`Result for ${newQuery}`);
      });
    };
    
    useEffect(() => {
      updateData(query);
    }, [query]);
    
    return { data, isPending, updateData };
  };

  const { result } = renderHook(useConcurrentData, {
    initialProps: "initial query",
    concurrentRoot: true
  });

  await waitFor(() => {
    expect(result.current.data).toBe("Result for initial query");
  });

  expect(result.current.isPending).toBe(false);

  // Test pending state during transition
  act(() => {
    result.current.updateData("new query");
  });

  // Might be pending briefly
  if (result.current.isPending) {
    await waitFor(() => {
      expect(result.current.isPending).toBe(false);
    });
  }

  expect(result.current.data).toBe("Result for new query");
});

test("hook with Suspense", async () => {
  const cache = new Map();
  
  const useSuspenseData = (key) => {
    if (!cache.has(key)) {
      const promise = new Promise(resolve => {
        setTimeout(() => resolve(`Data for ${key}`), 100);
      });
      cache.set(key, promise);
      throw promise;
    }
    
    const data = cache.get(key);
    if (data instanceof Promise) {
      throw data;
    }
    
    return data;
  };

  const SuspenseWrapper = ({ children }) => (
    <Suspense fallback={<Text>Loading...</Text>}>
      {children}
    </Suspense>
  );

  // This will initially throw and trigger Suspense
  const hookPromise = renderHookAsync(useSuspenseData, {
    initialProps: "test-key",
    wrapper: SuspenseWrapper
  });

  // Wait for Suspense to resolve
  const { result } = await hookPromise;

  await waitFor(() => {
    expect(result.current).toBe("Data for test-key");
  });
});

Install with Tessl CLI

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

docs

async-testing.md

configuration.md

hooks.md

index.md

interactions.md

matchers.md

queries.md

rendering.md

tile.json