← Back to DevBytes

Testing Yup Components: From Unit to E2E Tests

Testing Yup Components: From Unit to E2E Tests

Yup is a schema builder for parsing and validating runtime values. It is widely used in JavaScript and TypeScript applications to validate form inputs, API payloads, and configuration objects. While Yup itself is well-tested by its maintainers, the schemas you write are custom business logic — and business logic deserves tests. This tutorial walks through a complete testing strategy for Yup components, starting from isolated unit tests and ending with end-to-end (E2E) tests that verify validation behavior in a running application.

Why Testing Yup Schemas Matters

Validation schemas often encode critical rules: password strength, email format, required fields, conditional dependencies, and cross-field relationships. A bug in a schema can let invalid data into your database or block legitimate users from submitting a form. Manual testing through the UI is slow and error-prone, especially as schemas grow. Automated tests give you confidence that:

Project Setup

For this tutorial we will use Jest as the test runner, but the concepts apply equally to Vitest, Mocha, or Node's built-in test runner. Install the dependencies:

npm install yup jest @types/jest --save-dev

Create a sample schema that we will use throughout the tutorial. This schema represents a user registration form:

// src/schemas/userSchema.ts
import * as yup from 'yup';

export const userSchema = yup.object({
  username: yup
    .string()
    .required('Username is required')
    .min(3, 'Username must be at least 3 characters')
    .max(20, 'Username must be at most 20 characters')
    .matches(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores'),

  email: yup
    .string()
    .required('Email is required')
    .email('Must be a valid email'),

  password: yup
    .string()
    .required('Password is required')
    .min(8, 'Password must be at least 8 characters')
    .matches(/[A-Z]/, 'Password must contain at least one uppercase letter')
    .matches(/[0-9]/, 'Password must contain at least one number'),

  confirmPassword: yup
    .string()
    .required('Please confirm your password')
    .oneOf([yup.ref('password')], 'Passwords must match'),

  age: yup
    .number()
    .required('Age is required')
    .min(18, 'You must be at least 18 years old')
    .max(120, 'Please enter a valid age'),

  acceptTerms: yup
    .boolean()
    .oneOf([true], 'You must accept the terms and conditions'),
});

export type UserFormData = yup.InferType<typeof userSchema>;

Unit Testing Yup Schemas

Unit tests focus on the schema in isolation. The goal is to verify that individual fields and the schema as a whole accept valid data and reject invalid data with the correct error messages. Yup exposes two main methods for testing: validate() (validates a single value or object, stops at the first error) and validateSync() (synchronous version). For comprehensive error checking, use cast() carefully and validate() with the abortEarly: false option to collect all errors.

Testing Valid Inputs

Start by confirming that a fully valid payload passes validation:

// tests/userSchema.test.ts
import { userSchema } from '../src/schemas/userSchema';

describe('userSchema - valid inputs', () => {
  const validData = {
    username: 'john_doe123',
    email: 'john@example.com',
    password: 'Secret123',
    confirmPassword: 'Secret123',
    age: 25,
    acceptTerms: true,
  };

  it('accepts a fully valid payload', async () => {
    await expect(userSchema.validate(validData)).resolves.toEqual(validData);
  });

  it('accepts the minimum allowed username length', async () => {
    const data = { ...validData, username: 'abc' };
    await expect(userSchema.validate(data)).resolves.toBeDefined();
  });

  it('accepts the maximum allowed username length', async () => {
    const data = { ...validData, username: 'a'.repeat(20) };
    await expect(userSchema.validate(data)).resolves.toBeDefined();
  });
});

Testing Invalid Inputs and Error Messages

Yup throws a ValidationError when validation fails. The error object contains a message, a path (the field name), and, when using abortEarly: false, an inner array with every error. Use this to assert both that validation fails and that the correct message is produced:

describe('userSchema - invalid inputs', () => {
  const validData = {
    username: 'john_doe123',
    email: 'john@example.com',
    password: 'Secret123',
    confirmPassword: 'Secret123',
    age: 25,
    acceptTerms: true,
  };

  it('rejects a missing username', async () => {
    const { username, ...dataWithoutUsername } = validData;
    await expect(userSchema.validate(dataWithoutUsername)).rejects.toThrow(
      'Username is required'
    );
  });

  it('rejects a username that is too short', async () => {
    const data = { ...validData, username: 'ab' };
    await expect(userSchema.validate(data)).rejects.toThrow(
      'Username must be at least 3 characters'
    );
  });

  it('rejects a username with special characters', async () => {
    const data = { ...validData, username: 'john@doe' };
    await expect(userSchema.validate(data)).rejects.toThrow(
      'Username can only contain letters, numbers, and underscores'
    );
  });

  it('rejects an invalid email', async () => {
    const data = { ...validData, email: 'not-an-email' };
    await expect(userSchema.validate(data)).rejects.toThrow('Must be a valid email');
  });

  it('rejects a password without an uppercase letter', async () => {
    const data = { ...validData, password: 'secret123', confirmPassword: 'secret123' };
    await expect(userSchema.validate(data)).rejects.toThrow(
      'Password must contain at least one uppercase letter'
    );
  });

  it('rejects mismatched passwords', async () => {
    const data = { ...validData, confirmPassword: 'Different123' };
    await expect(userSchema.validate(data)).rejects.toThrow('Passwords must match');
  });

  it('rejects an age below 18', async () => {
    const data = { ...validData, age: 17 };
    await expect(userSchema.validate(data)).rejects.toThrow(
      'You must be at least 18 years old'
    );
  });

  it('rejects when terms are not accepted', async () => {
    const data = { ...validData, acceptTerms: false };
    await expect(userSchema.validate(data)).rejects.toThrow(
      'You must accept the terms and conditions'
    );
  });
});

Collecting All Errors at Once

When you want to verify that multiple fields fail simultaneously, use abortEarly: false and inspect the inner array of the thrown error:

describe('userSchema - multiple errors', () => {
  it('returns errors for every invalid field', async () => {
    const invalidData = {
      username: 'ab',
      email: 'bad',
      password: 'weak',
      confirmPassword: 'different',
      age: 10,
      acceptTerms: false,
    };

    try {
      await userSchema.validate(invalidData, { abortEarly: false });
      fail('Validation should have failed');
    } catch (error: any) {
      const errorPaths = error.inner.map((e: any) => e.path);
      expect(errorPaths).toEqual(
        expect.arrayContaining([
          'username',
          'email',
          'password',
          'confirmPassword',
          'age',
          'acceptTerms',
        ])
      );
      expect(error.inner.length).toBeGreaterThanOrEqual(6);
    }
  });
});

Testing Conditional and Cross-Field Logic

Real-world schemas often include conditional rules using when() or cross-field references using yup.ref(). These deserve dedicated tests because their behavior depends on the combination of multiple fields. Consider an extended schema for a shipping address where the postal code format depends on the country:

// src/schemas/addressSchema.ts
import * as yup from 'yup';

export const addressSchema = yup.object({
  country: yup.string().required('Country is required'),
  postalCode: yup
    .string()
    .required('Postal code is required')
    .when('country', {
      is: 'US',
      then: (schema) => schema.matches(/^\d{5}(-\d{4})?$/, 'Invalid US ZIP code'),
      otherwise: (schema) => schema.matches(/^[A-Za-z]\d[A-Za-z] ?\d[A-Za-z]\d$/, 'Invalid Canadian postal code'),
    }),
  isResidential: yup.boolean().required(),
  company: yup.string().when('isResidential', {
    is: false,
    then: (schema) => schema.required('Company name is required for commercial addresses'),
    otherwise: (schema) => schema.notRequired(),
  }),
});

Test each branch of the conditional logic explicitly:

// tests/addressSchema.test.ts
import { addressSchema } from '../src/schemas/addressSchema';

describe('addressSchema - conditional validation', () => {
  it('accepts a valid US ZIP code', async () => {
    const data = {
      country: 'US',
      postalCode: '12345',
      isResidential: true,
    };
    await expect(addressSchema.validate(data)).resolves.toBeDefined();
  });

  it('accepts a US ZIP+4 format', async () => {
    const data = {
      country: 'US',
      postalCode: '12345-6789',
      isResidential: true,
    };
    await expect(addressSchema.validate(data)).resolves.toBeDefined();
  });

  it('rejects an invalid US ZIP code', async () => {
    const data = {
      country: 'US',
      postalCode: 'ABCDE',
      isResidential: true,
    };
    await expect(addressSchema.validate(data)).rejects.toThrow('Invalid US ZIP code');
  });

  it('accepts a valid Canadian postal code', async () => {
    const data = {
      country: 'CA',
      postalCode: 'K1A 0B1',
      isResidential: true,
    };
    await expect(addressSchema.validate(data)).resolves.toBeDefined();
  });

  it('requires company name for commercial addresses', async () => {
    const data = {
      country: 'US',
      postalCode: '12345',
      isResidential: false,
    };
    await expect(addressSchema.validate(data)).rejects.toThrow(
      'Company name is required for commercial addresses'
    );
  });

  it('does not require company name for residential addresses', async () => {
    const data = {
      country: 'US',
      postalCode: '12345',
      isResidential: true,
    };
    await expect(addressSchema.validate(data)).resolves.toBeDefined();
  });
});

Testing Custom Yup Validators

Yup lets you add custom validation methods via addMethod() or inline test functions. These are prime candidates for thorough unit testing because they contain bespoke logic. Here is a custom validator that checks for a strong password:

// src/schemas/validators.ts
import * as yup from 'yup';

yup.addMethod(yup.string, 'strongPassword', function (message: string) {
  return this.test('strong-password', message, function (value) {
    const { path, createError } = this;
    if (!value) return true; // let required() handle emptiness

    const hasLower = /[a-z]/.test(value);
    const hasUpper = /[A-Z]/.test(value);
    const hasNumber = /[0-9]/.test(value);
    const hasSpecial = /[!@#$%^&*(),.?":{}|<>]/.test(value);
    const isLongEnough = value.length >= 10;

    if (!(hasLower && hasUpper && hasNumber && hasSpecial && isLongEnough)) {
      return createError({ path, message });
    }
    return true;
  });
});

declare module 'yup' {
  interface StringSchema {
    strongPassword(message: string): this;
  }
}

Test the custom validator across a range of inputs:

// tests/validators.test.ts
import * as yup from 'yup';
import './src/schemas/validators'; // register the custom method

const passwordSchema = yup.string().strongPassword('Password is not strong enough');

describe('strongPassword custom validator', () => {
  it('accepts a strong password', async () => {
    await expect(passwordSchema.validate('Str0ng!Pass')).resolves.toBe('Str0ng!Pass');
  });

  it('rejects a password without a special character', async () => {
    await expect(passwordSchema.validate('Str0ngPass')).rejects.toThrow(
      'Password is not strong enough'
    );
  });

  it('rejects a password without a number', async () => {
    await expect(passwordSchema.validate('Strong!Pass')).rejects.toThrow(
      'Password is not strong enough'
    );
  });

  it('rejects a password shorter than 10 characters', async () => {
    await expect(passwordSchema.validate('Str0!')).rejects.toThrow(
      'Password is not strong enough'
    );
  });

  it('allows empty values so required() can handle them', async () => {
    await expect(passwordSchema.validate('')).resolves.toBe('');
  });
});

Integration Testing with React Hook Form

Unit tests verify the schema in isolation, but in practice schemas are wired into form libraries like React Hook Form or Formik. Integration tests confirm that the schema and the form library work together correctly. We will use React Testing Library alongside React Hook Form.

Install the additional dependencies:

npm install react react-hook-form @testing-library/react @testing-library/jest-dom @testing-library/user-event --save-dev

Here is a registration form component that uses our schema:

// src/components/RegistrationForm.tsx
import React from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import { userSchema, UserFormData } from '../schemas/userSchema';

export function RegistrationForm({ onSubmit }: { onSubmit: (data: UserFormData) => void }) {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<UserFormData>({
    resolver: yupResolver(userSchema),
  });

  return (
    <form onSubmit={handleSubmit(onSubmit)} data-testid="registration-form">
      <div>
        <label htmlFor="username">Username</label>
        <input id="username" {...register('username')} />
        {errors.username && <span role="alert">{errors.username.message}</span>}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input id="email" {...register('email')} />
        {errors.email && <span role="alert">{errors.email.message}</span>}
      </div>

      <div>
        <label htmlFor="password">Password</label>
        <input id="password" type="password" {...register('password')} />
        {errors.password && <span role="alert">{errors.password.message}</span>}
      </div>

      <div>
        <label htmlFor="confirmPassword">Confirm Password</label>
        <input id="confirmPassword" type="password" {...register('confirmPassword')} />
        {errors.confirmPassword && <span role="alert">{errors.confirmPassword.message}</span>}
      </div>

      <div>
        <label htmlFor="age">Age</label>
        <input id="age" type="number" {...register('age')} />
        {errors.age && <span role="alert">{errors.age.message}</span>}
      </div>

      <div>
        <label>
          <input type="checkbox" {...register('acceptTerms')} />
          I accept the terms and conditions
        </label>
        {errors.acceptTerms && <span role="alert">{errors.acceptTerms.message}</span>}
      </div>

      <button type="submit">Register</button>
    </form>
  );
}

Now write integration tests that simulate user interaction and verify that Yup errors surface in the UI:

// tests/RegistrationForm.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { RegistrationForm } from '../src/components/RegistrationForm';

describe('RegistrationForm integration', () => {
  const mockSubmit = jest.fn();

  beforeEach(() => {
    mockSubmit.mockClear();
  });

  it('displays validation errors when the form is submitted empty', async () => {
    const user = userEvent.setup();
    render(<RegistrationForm onSubmit={mockSubmit} />);

    await user.click(screen.getByText('Register'));

    expect(await screen.findByText('Username is required')).toBeInTheDocument();
    expect(screen.getByText('Email is required')).toBeInTheDocument();
    expect(screen.getByText('Password is required')).toBeInTheDocument();
    expect(screen.getByText('You must accept the terms and conditions')).toBeInTheDocument();
    expect(mockSubmit).not.toHaveBeenCalled();
  });

  it('shows a mismatch error when passwords do not match', async () => {
    const user = userEvent.setup();
    render(<RegistrationForm onSubmit={mockSubmit} />);

    await user.type(screen.getByLabelText(/username/i), 'john_doe');
    await user.type(screen.getByLabelText(/email/i), 'john@example.com');
    await user.type(screen.getByLabelText(/^password$/i), 'Secret123');
    await user.type(screen.getByLabelText(/confirm password/i), 'Different123');
    await user.type(screen.getByLabelText(/age/i), '25');
    await user.click(screen.getByLabelText(/accept the terms/i));
    await user.click(screen.getByText('Register'));

    expect(await screen.findByText('Passwords must match')).toBeInTheDocument();
    expect(mockSubmit).not.toHaveBeenCalled();
  });

  it('submits valid data successfully', async () => {
    const user = userEvent.setup();
    render(<RegistrationForm onSubmit={mockSubmit} />);

    await user.type(screen.getByLabelText(/username/i), 'john_doe');
    await user.type(screen.getByLabelText(/email/i), 'john@example.com');
    await user.type(screen.getByLabelText(/^password$/i), 'Secret123');
    await user.type(screen.getByLabelText(/confirm password/i), 'Secret123');
    await user.type(screen.getByLabelText(/age/i), '25');
    await user.click(screen.getByLabelText(/accept the terms/i));
    await user.click(screen.getByText('Register'));

    expect(mockSubmit).toHaveBeenCalledTimes(1);
    expect(mockSubmit).toHaveBeenCalledWith(
      expect.objectContaining({
        username: 'john_doe',
        email: 'john@example.com',
        age: 25,
        acceptTerms: true,
      })
    );
  });
});

End-to-End Testing with Playwright

Integration tests run in a simulated DOM, but they do not catch issues that only appear in a real browser with a running backend. E2E tests validate the full stack: the browser renders the form, the user types into real input elements, Yup validates on the client, and the submission reaches the server. We will use Playwright for this layer.

Install Playwright:

npm install @playwright/test --save-dev
npx playwright install

Assume the registration form is served at /register. Write an E2E test that exercises both the happy path and validation failures:

// e2e/registration.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Registration form E2E', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/register');
  });

  test('shows validation errors on empty submission', async ({ page }) => {
    await page.click('button[type="submit"]');

    await expect(page.locator('text=Username is required')).toBeVisible();
    await expect(page.locator('text=Email is required')).toBeVisible();
    await expect(page.locator('text=Password is required')).toBeVisible();
    await expect(page.locator('text=You must accept the terms and conditions')).toBeVisible();
  });

  test('rejects a weak password with the correct message', async ({ page }) => {
    await page.fill('#username', 'john_doe');
    await page.fill('#email', 'john@example.com');
    await page.fill('#password', 'weak');
    await page.fill('#confirmPassword', 'weak');
    await page.fill('#age', '25');
    await page.check('input[type="checkbox"]');
    await page.click('button[type="submit"]');

    await expect(page.locator('text=Password must be at least 8 characters')).toBeVisible();
  });

  test('completes registration with valid data', async ({ page }) => {
    await page.fill('#username', 'john_doe');
    await page.fill('#email', 'john@example.com');
    await page.fill('#password', 'Secret123');
    await page.fill('#confirmPassword', 'Secret123');
    await page.fill('#age', '25');
    await page.check('input[type="checkbox"]');
    await page.click('button[type="submit"]');

    // Wait for navigation to the success page or a success message
    await expect(page).toHaveURL(/\/welcome/);
    await expect(page.locator('h1')).toContainText('Welcome, john_doe');
  });

  test('prevents duplicate email registration', async ({ page }) => {
    await page.fill('#username', 'john_doe');
    await page.fill('#email', 'existing@example.com');
    await page.fill('#password', 'Secret123');
    await page.fill('#confirmPassword', 'Secret123');
    await page.fill('#age', '25');
    await page.check('input[type="checkbox"]');
    await page.click('button[type="submit"]');

    await expect(page.locator('text=Email already registered')).toBeVisible();
  });
});

Run the E2E suite with:

npx playwright test

Best Practices

Test Both Sides of Every Boundary

For every numeric or length constraint, test the exact boundary value on both sides. If min(18) is the rule, test 17 (should fail) and 18 (should pass). Off-by-one errors are the most common schema bugs.

Keep Schema Tests Independent of the UI

Unit tests for schemas should not render any React components. This keeps them fast and focused. If a schema test fails, you immediately know the problem is in the schema, not the form library or the DOM.

Snapshot Test Error Shapes Sparingly

It can be tempting to snapshot the entire ValidationError object, but this creates brittle tests that break when Yup changes its internal error structure. Instead, assert on message and path explicitly.

Use a Factory for Valid Data

As schemas grow, constructing a valid object in every test becomes repetitive. Use a factory function that returns a known-good payload, then override individual fields per test:

// tests/factories.ts
export function createValidUser(overrides: Partial<UserFormData> = {}): UserFormData {
  return {
    username: 'john_doe123',
    email: 'john@example.com',
    password: 'Secret123',
    confirmPassword: 'Secret123',
    age: 25,
    acceptTerms: true,
    ...overrides,
  };
}

// Usage in a test:
it('rejects a username with spaces', async () => {
  const data = createValidUser({ username: 'john doe' });
  await expect(userSchema.validate(data)).rejects.toThrow(
    'Username can only contain letters, numbers, and underscores'
  );
});

Test Error Messages, Not Just Failure

Asserting that validation throws is not enough. Users see the error messages, so verify the exact text. This also doubles as documentation of what the user will experience.

Mock the Backend in E2E Tests When Appropriate

For E2E tests that focus on form validation rather than server behavior, intercept network requests with Playwright's page.route() and return canned responses. This keeps tests deterministic and fast:

test('shows server error on duplicate email', async ({ page }) => {
  await page.route('**/api/register', (route) => {
    route.fulfill({
      status: 409,
      contentType: 'application/json',
      body: JSON.stringify({ error: 'Email already registered' }),
    });
  });

  await page.goto('/register');
  // ... fill and submit the form ...
  await expect(page.locator('text=Email already registered')).toBeVisible();
});

Organize Tests by Concern

Group tests with describe blocks by field or by feature. This makes failures easy to locate and keeps the test file readable as it grows. A common pattern is one describe block per field, plus a block for cross-field and full-schema tests.

Conclusion

Testing Yup components is a layered effort that pays off as your schemas grow in complexity. Unit tests give you fast, precise feedback on individual fields and custom validators. Integration tests confirm that your schema and form library cooperate to surface the right errors in the UI. E2E tests provide the ultimate confidence that validation works in a real browser against a real backend. By testing boundary values, conditional logic, error messages, and the full user journey, you build a safety net that lets you refactor schemas fearlessly and ship validation changes with confidence. Start with unit tests for every schema you write, add integration tests for the forms that consume them, and reserve E2E tests for the critical paths that matter most to your users.

— Ad —

Google AdSense will appear here after approval

← Back to all articles