I'm Going to Show You My Bad Tests (Don't Laugh)

By Odilon13 min read

I'm Going to Show You My Bad Tests (Don't Laugh)#

I'm going to show you some tests I wrote 6 months ago. Don't laugh.

Actually, go ahead and laugh. I would. These tests are terrible. And that's the point -- because understanding WHY they're terrible is more useful than showing you perfect tests.

When I started writing React tests, I made every mistake in the book. I tested that components rendered specific class names. I asserted that certain functions were called with certain arguments. I used Enzyme's shallow rendering and checked that child components received specific props. My test suite had 200 tests and gave me almost no confidence that the app actually worked.

The problem wasn't quantity. It was philosophy. I was testing implementation, not behavior. Every refactor broke 10 tests. Every rename broke 5 more. The tests felt like a burden, not a safety net.

The Bad Tests (Circa Late 2020)#

Here's what my tests looked like. I'm cringing as I paste this:

typescript
// WHAT I USED TO WRITE -- please don't do this
import { shallow } from 'enzyme';

describe('ProductCard', () => {
  it('renders with the correct class name', () => {
    const wrapper = shallow(<ProductCard product={mockProduct} />);
    expect(wrapper.find('.product-card__title')).toHaveLength(1);
    expect(wrapper.find('.product-card__price')).toHaveLength(1);
    // what does this even tell me? that CSS classes exist?
  });

  it('passes the right props to child components', () => {
    const wrapper = shallow(<ProductCard product={mockProduct} />);
    const priceDisplay = wrapper.find('PriceDisplay');
    expect(priceDisplay.prop('amount')).toBe(29.99);
    expect(priceDisplay.prop('currency')).toBe('USD');
    // breaks the instant I rename PriceDisplay or change its props
  });

  it('calls the handler when clicked', () => {
    const onAdd = jest.fn();
    const wrapper = shallow(<ProductCard product={mockProduct} onAddToCart={onAdd} />);
    wrapper.find('.product-card__add-btn').simulate('click');
    expect(onAdd).toHaveBeenCalledWith('prod-123');
    // tied to a CSS class that might change tomorrow
  });
});

Every single one of these tests is fragile. Rename a CSS class? Three tests break. Restructure the component hierarchy? Five tests break. Change an internal component name? More tests break. And none of these tests tell me if the user can actually see the product name and click the button.

What Changed: React Testing Library#

React Testing Library changed everything by making one strong opinion: test your app the way a user uses it. Users don't care about CSS classes. They don't know what components are called. They see text, they click buttons, they fill in forms.

typescript
import { render, screen } from '@testing-library/react';

// AVOID: queries tied to implementation
const button = container.querySelector('.btn-primary');
const header = wrapper.find('ProductHeader');

// PREFER: queries tied to what users actually see
const button = screen.getByRole('button', { name: 'Add to cart' });
const heading = screen.getByRole('heading', { name: 'Product Details' });
const emailInput = screen.getByLabelText('Email address');
const price = screen.getByText('$29.99');

The query priority order (from Dan Abramov and Kent C. Dodds, who I trust on this more than myself):

  1. Role (getByRole) -- what screen readers see
  2. Label text (getByLabelText) -- for form inputs
  3. Placeholder (getByPlaceholderText) -- fallback for inputs
  4. Text content (getByText) -- visible text
  5. Display value (getByDisplayValue) -- current input value
  6. Alt text (getByAltText) -- for images
  7. Test ID (getByTestId) -- last resort

The Same Test, But Good#

Here's the sign-in form test I wrote AFTER I understood the philosophy:

typescript
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SignInForm } from './sign-in-form';

const mockSignIn = vi.fn();
vi.mock('../services/auth', () => ({
  signIn: (email: string, password: string) => mockSignIn(email, password),
}));

describe('SignInForm', () => {
  beforeEach(() => {
    mockSignIn.mockReset();
  });

  it('submits valid credentials', async () => {
    const user = userEvent.setup();
    const onSuccess = vi.fn();
    render(<SignInForm onSuccess={onSuccess} />);

    // interact like a user, not like a querySelector
    await user.type(screen.getByLabelText('Email'), 'user@example.com');
    await user.type(screen.getByLabelText('Password'), 'secure-password-123');
    await user.click(screen.getByRole('button', { name: 'Sign in' }));

    expect(mockSignIn).toHaveBeenCalledWith('user@example.com', 'secure-password-123');
  });

  it('shows validation error for empty email', async () => {
    const user = userEvent.setup();
    render(<SignInForm onSuccess={vi.fn()} />);

    await user.type(screen.getByLabelText('Password'), 'some-password');
    await user.click(screen.getByRole('button', { name: 'Sign in' }));

    expect(screen.getByRole('alert')).toHaveTextContent('Email is required');
    expect(mockSignIn).not.toHaveBeenCalled();
  });

  it('shows error when sign-in fails', async () => {
    mockSignIn.mockRejectedValue(new Error('Invalid credentials'));
    const user = userEvent.setup();
    render(<SignInForm onSuccess={vi.fn()} />);

    await user.type(screen.getByLabelText('Email'), 'user@example.com');
    await user.type(screen.getByLabelText('Password'), 'wrong-password');
    await user.click(screen.getByRole('button', { name: 'Sign in' }));

    await waitFor(() => {
      expect(screen.getByRole('alert')).toHaveTextContent('Invalid credentials');
    });
  });

  it('disables button while signing in', async () => {
    mockSignIn.mockReturnValue(new Promise(() => {})); // never resolves
    const user = userEvent.setup();
    render(<SignInForm onSuccess={vi.fn()} />);

    await user.type(screen.getByLabelText('Email'), 'user@example.com');
    await user.type(screen.getByLabelText('Password'), 'secure-password-123');
    await user.click(screen.getByRole('button', { name: 'Sign in' }));

    expect(screen.getByRole('button', { name: /signing in/i })).toBeDisabled();
  });
});

The difference: I can completely restructure the form's DOM, rename every CSS class, extract subcomponents, change the internal state management -- and these tests won't break. They only break when the behavior changes. Which is exactly when I WANT them to break.

Providers and Wrappers#

Real components need providers. Router, QueryClient, theme, auth. Don't duplicate that in every test:

typescript
import { render, screen } from '@testing-library/react';

// Build a reusable wrapper once
function renderWithProviders(ui: React.ReactElement) {
  return render(
    <QueryClientProvider client={new QueryClient()}>
      <MemoryRouter>
        {ui}
      </MemoryRouter>
    </QueryClientProvider>
  );
}

// Or use a custom render
import { render as rtlRender, RenderOptions } from '@testing-library/react';

function customRender(ui: React.ReactElement, options?: RenderOptions) {
  return rtlRender(ui, {
    wrapper: ({ children }) => (
      <AppProviders>{children}</AppProviders>
    ),
    ...options,
  });
}

Async Testing: Where the Real Bugs Live#

Most React bugs live in async paths. After a fetch completes, after a timeout fires, after a state update triggers a side effect. A test suite with 100% passing tests can still ship a bug that only appears when the API is slow.

typescript
import { render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react';

it('loads and displays user data', async () => {
  render(<UserProfile userId="123" />);

  // Wait for loading state to clear
  await waitForElementToBeRemoved(() => screen.getByText('Loading...'));

  expect(screen.getByRole('heading', { name: 'Jane Smith' })).toBeInTheDocument();
  expect(screen.getByText('jane@example.com')).toBeInTheDocument();
});

it('shows error state when fetch fails', async () => {
  server.use(
    rest.get('/api/users/123', (req, res, ctx) => {
      return res(ctx.status(500));
    })
  );

  render(<UserProfile userId="123" />);

  await waitFor(() => {
    expect(screen.getByRole('alert')).toBeInTheDocument();
  });

  expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
});

Mocking at the Right Level: MSW#

Don't mock your internal modules. Mock the network boundary. Mock Service Worker (MSW) intercepts fetch calls at the network level, so your component code runs exactly as it does in production:

typescript
import { rest } from 'msw';
import { setupServer } from 'msw/node';

const server = setupServer(
  rest.get('/api/products', (req, res, ctx) => {
    return res(
      ctx.json([
        { id: '1', name: 'Widget Pro', price: 49.99, sku: 'WGT-001' },
        { id: '2', name: 'Gadget Plus', price: 29.99, sku: 'GDG-002' },
      ])
    );
  }),

  rest.post('/api/cart/items', async (req, res, ctx) => {
    const body = await req.json();
    return res(ctx.status(201), ctx.json({ ...body, id: 'cart-item-1' }));
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

describe('ProductListing', () => {
  it('renders the product list', async () => {
    render(<ProductListing />);

    await waitFor(() => {
      expect(screen.getByText('Widget Pro')).toBeInTheDocument();
    });

    expect(screen.getByText('$49.99')).toBeInTheDocument();
    expect(screen.getByText('Gadget Plus')).toBeInTheDocument();
  });
});

Testing Custom Hooks#

renderHook from React Testing Library makes this clean:

typescript
import { renderHook, act } from '@testing-library/react';
import { useCartTotals } from './use-cart-totals';

const sampleItems = [
  { productId: '1', name: 'Widget', price: 10, quantity: 2 },
  { productId: '2', name: 'Gadget', price: 25, quantity: 1 },
];

describe('useCartTotals', () => {
  it('computes subtotal correctly', () => {
    const { result } = renderHook(() => useCartTotals(sampleItems));
    expect(result.current.subtotal).toBe(45); // 10*2 + 25*1
  });

  it('applies discount correctly', () => {
    const { result } = renderHook(() => useCartTotals(sampleItems));
    act(() => {
      result.current.applyDiscount('SAVE10', 0.1);
    });
    expect(result.current.discount).toBeCloseTo(4.5);
    expect(result.current.total).toBeCloseTo(40.5);
  });
});

What I Stopped Testing#

Just as important as knowing what to test:

typescript
// DON'T test CSS classes -- fragile, tests implementation
expect(button).toHaveClass('btn-primary');

// DON'T test internal state
// (Not even possible with RTL. Good.)

// DON'T test that child components got specific props
// Test the output, not the wiring

// DON'T test that React's useState works
// React has its own test suite. Trust it.

The Shift That Took Two Months#

The move from "test implementation" to "test behavior" took me about two months of deliberate practice. The first sign it was working: my tests started surviving refactors that didn't change user-visible behavior. The second sign: when a test broke, it was always because something a user would actually notice had broken.

My test distribution for a typical React app: 70% integration tests (components with hooks and real network mocks), 20% unit tests (pure utilities, complex business logic), 10% E2E (critical user journeys in a real browser).

Common Pitfalls#

getBy when you need findBy. This one bit me so many times. getBy throws immediately if the element isn't there. findBy returns a promise and waits. If you're testing content that appears after an async operation, use findBy:

typescript
// WRONG: throws before async content loads
const heading = screen.getByRole('heading', { name: 'Dashboard' });

// RIGHT: waits for the element
const heading = await screen.findByRole('heading', { name: 'Dashboard' });

Manual act() wrapping. RTL wraps its utilities in act automatically. If you're wrapping things in act manually, you're probably triggering state updates outside of user event utilities. Use userEvent from @testing-library/user-event instead of fireEvent -- it's more realistic anyway.

Testing the same thing twice. If you have an integration test for a form's happy path, you don't need unit tests for individual field validation. The integration test covers it. Focus unit tests on logic that integration tests don't reach.

Not testing the error path. I spent 3 months writing only happy-path tests. Then a production bug happened because the error toast was rendering behind a modal. Test your error states. Test your loading states. Test the stuff that goes wrong, because it will.

Write tests that break when users would notice something broke. Everything else is noise.

I'm Going to Show You My Bad Tests (Don't Laugh) | Blog