Testing NativeWind v4: className Assertions, Dark Mode, and Responsive Breakpoints

Testing NativeWind v4: className Assertions, Dark Mode, and Responsive Breakpoints

NativeWind v4 brings Tailwind CSS utility classes to React Native using a compiler that converts className strings into StyleSheet-compatible styles. Testing NativeWind components requires understanding what the compiler produces and how to assert it.

This guide covers NativeWind v4 testing: className-based assertions, dark mode switching, responsive breakpoints, and custom theme variants.

How NativeWind v4 Works in Tests

NativeWind v4 works differently from v2. The compiler processes className at build time and replaces it with a style prop (or a CSS variables system on web). In tests:

  • jest-expo and the NativeWind Babel plugin transform className props
  • The resulting styles are plain React Native StyleSheet objects
  • RNTL's toHaveStyle() matcher works against these computed styles
npm install nativewind
npm install -D nativewind/babel

babel.config.js:

module.exports = {
  presets: ['babel-preset-expo'],
  plugins: ['nativewind/babel'],
}

Setup for Tests

// jest-setup.ts
import '@testing-library/jest-native/extend-expect'

// NativeWind v4 requires the colorScheme state to be initialized
import { colorScheme } from 'nativewind'
colorScheme.set('light') // Default to light mode in tests

Testing className Assertions

The simplest approach: test the rendered output, not the className string. Verify that the component looks correct for a given state:

// components/Button.tsx
import { Pressable, Text } from 'react-native'
import { styled } from 'nativewind'

const StyledPressable = styled(Pressable)
const StyledText = styled(Text)

interface ButtonProps {
  label: string
  variant?: 'primary' | 'secondary' | 'danger'
  disabled?: boolean
  onPress?: () => void
}

export function Button({ label, variant = 'primary', disabled, onPress }: ButtonProps) {
  return (
    <StyledPressable
      className={`
        px-4 py-2 rounded-lg
        ${variant === 'primary' ? 'bg-green-500' : ''}
        ${variant === 'secondary' ? 'bg-gray-200' : ''}
        ${variant === 'danger' ? 'bg-red-500' : ''}
        ${disabled ? 'opacity-50' : ''}
      `}
      disabled={disabled}
      onPress={onPress}
      testID="button"
    >
      <StyledText
        className={`
          font-semibold text-base
          ${variant === 'secondary' ? 'text-gray-800' : 'text-white'}
        `}
      >
        {label}
      </StyledText>
    </StyledPressable>
  )
}
// components/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react-native'
import { describe, it, expect, jest } from '@jest/globals'
import { Button } from './Button'

describe('Button', () => {
  it('renders label text', () => {
    render(<Button label="Save changes" />)

    expect(screen.getByText('Save changes')).toBeOnTheScreen()
  })

  it('calls onPress handler', () => {
    const onPress = jest.fn()
    render(<Button label="Submit" onPress={onPress} />)

    fireEvent.press(screen.getByTestId('button'))

    expect(onPress).toHaveBeenCalledOnce()
  })

  it('is not pressable when disabled', () => {
    const onPress = jest.fn()
    render(<Button label="Submit" disabled onPress={onPress} />)

    fireEvent.press(screen.getByTestId('button'))

    expect(onPress).not.toHaveBeenCalled()
  })

  it('applies opacity style when disabled', () => {
    render(<Button label="Submit" disabled />)

    const button = screen.getByTestId('button')
    // NativeWind compiles className to StyleSheet objects
    expect(button).toHaveStyle({ opacity: 0.5 })
  })
})

Testing Dark Mode

NativeWind v4 uses colorScheme from nativewind to switch modes. Tests can set the color scheme per-test:

// components/Card.tsx
import { View, Text } from 'react-native'
import { styled } from 'nativewind'

const StyledView = styled(View)
const StyledText = styled(Text)

export function Card({ title, body }: { title: string; body: string }) {
  return (
    <StyledView
      className="bg-white dark:bg-gray-900 p-4 rounded-xl"
      testID="card"
    >
      <StyledText
        className="text-gray-900 dark:text-white font-semibold text-lg"
        testID="card-title"
      >
        {title}
      </StyledText>
      <StyledText
        className="text-gray-600 dark:text-gray-300 text-sm mt-1"
        testID="card-body"
      >
        {body}
      </StyledText>
    </StyledView>
  )
}
// components/Card.test.tsx
import { render, screen } from '@testing-library/react-native'
import { describe, it, expect, afterEach } from '@jest/globals'
import { colorScheme } from 'nativewind'
import { Card } from './Card'

describe('Card', () => {
  afterEach(() => {
    colorScheme.set('light') // Reset after each test
  })

  it('renders with light mode styles', () => {
    colorScheme.set('light')
    render(<Card title="Test Card" body="Card content" />)

    const card = screen.getByTestId('card')
    expect(card).toHaveStyle({ backgroundColor: '#ffffff' })

    const title = screen.getByTestId('card-title')
    expect(title).toHaveStyle({ color: '#111827' }) // gray-900
  })

  it('renders with dark mode styles', () => {
    colorScheme.set('dark')
    render(<Card title="Test Card" body="Card content" />)

    const card = screen.getByTestId('card')
    expect(card).toHaveStyle({ backgroundColor: '#111827' }) // gray-900

    const title = screen.getByTestId('card-title')
    expect(title).toHaveStyle({ color: '#ffffff' })
  })
})

Tip: Always reset colorScheme to 'light' in afterEach to prevent dark mode tests from bleeding into others.

Testing Responsive Breakpoints

NativeWind v4 supports responsive prefixes (sm:, md:, lg:) using window dimensions. In tests, mock the dimensions to simulate different screen sizes:

// components/Grid.tsx
import { View } from 'react-native'
import { styled } from 'nativewind'

const StyledView = styled(View)

interface GridProps {
  children: React.ReactNode
}

// Single column on small, two columns on medium, three on large
export function Grid({ children }: GridProps) {
  return (
    <StyledView
      className="flex-1 flex-col md:flex-row flex-wrap"
      testID="grid"
    >
      {children}
    </StyledView>
  )
}
// components/Grid.test.tsx
import { render, screen } from '@testing-library/react-native'
import { Dimensions } from 'react-native'
import { describe, it, expect, afterEach, jest } from '@jest/globals'
import { Grid } from './Grid'

describe('Grid responsive layout', () => {
  afterEach(() => {
    // Restore default dimensions
    jest.spyOn(Dimensions, 'get').mockRestore()
  })

  it('renders as single column on small screen (375px)', () => {
    jest.spyOn(Dimensions, 'get').mockReturnValue({ width: 375, height: 812, scale: 3, fontScale: 1 })

    render(<Grid><View /><View /></Grid>)

    const grid = screen.getByTestId('grid')
    expect(grid).toHaveStyle({ flexDirection: 'column' })
  })

  it('renders as row on medium screen (768px+)', () => {
    jest.spyOn(Dimensions, 'get').mockReturnValue({ width: 768, height: 1024, scale: 2, fontScale: 1 })

    render(<Grid><View /><View /></Grid>)

    const grid = screen.getByTestId('grid')
    expect(grid).toHaveStyle({ flexDirection: 'row' })
  })
})

Testing Custom Variants

NativeWind v4 supports custom theme variants via tailwind.config.js. Test that custom tokens apply correctly:

// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      colors: {
        brand: {
          DEFAULT: '#5aff28',
          dark: '#3dd61a',
        },
      },
      borderRadius: {
        card: '12px',
      },
    },
  },
}
// components/BrandButton.tsx
import { Pressable, Text } from 'react-native'
import { styled } from 'nativewind'

const StyledPressable = styled(Pressable)
const StyledText = styled(Text)

export function BrandButton({ label, onPress }: { label: string; onPress?: () => void }) {
  return (
    <StyledPressable
      className="bg-brand rounded-card px-6 py-3"
      onPress={onPress}
      testID="brand-button"
    >
      <StyledText className="text-black font-bold">{label}</StyledText>
    </StyledPressable>
  )
}
describe('BrandButton', () => {
  it('applies brand background color', () => {
    render(<BrandButton label="Get started" />)

    const button = screen.getByTestId('brand-button')
    expect(button).toHaveStyle({ backgroundColor: '#5aff28' })
  })

  it('applies card border radius', () => {
    render(<BrandButton label="Get started" />)

    const button = screen.getByTestId('brand-button')
    expect(button).toHaveStyle({ borderRadius: 12 })
  })
})

Testing Animated Styles

When NativeWind classes are combined with Animated or react-native-reanimated, test the static state and the animated state separately:

// components/Toast.tsx
import { Animated } from 'react-native'
import { styled } from 'nativewind'
import { useRef, useEffect } from 'react'

const StyledAnimated = styled(Animated.View)

export function Toast({ message, visible }: { message: string; visible: boolean }) {
  const opacity = useRef(new Animated.Value(0)).current

  useEffect(() => {
    Animated.timing(opacity, {
      toValue: visible ? 1 : 0,
      duration: 200,
      useNativeDriver: true,
    }).start()
  }, [visible])

  return (
    <StyledAnimated
      className="bg-gray-900 px-4 py-2 rounded-lg"
      style={{ opacity }}
      testID="toast"
    >
      <Text className="text-white">{message}</Text>
    </StyledAnimated>
  )
}
describe('Toast', () => {
  it('renders message text', () => {
    render(<Toast message="Saved successfully" visible />)
    expect(screen.getByText('Saved successfully')).toBeOnTheScreen()
  })

  it('applies base NativeWind styles', () => {
    render(<Toast message="Test" visible />)
    const toast = screen.getByTestId('toast')
    expect(toast).toHaveStyle({ borderRadius: 8 }) // rounded-lg
  })
})

For animation state assertions, use jest.useFakeTimers() and advance timers.

What Automated Tests Miss

Unit tests with NativeWind don't cover:

  • Actual visual rendering — the computed style values may be correct but the visual output wrong on specific devices
  • Font scaling — NativeWind doesn't control system font size; fontScale affects layout
  • Platform-specific style overrides — iOS and Android render some styles differently
  • CSS variable inheritance — on Expo web, NativeWind uses CSS variables that behave differently than StyleSheet values

HelpMeTest runs visual tests against your deployed app to catch rendering differences that style assertions miss.

Summary

Testing NativeWind v4:

  • className assertions → use toHaveStyle() on compiled StyleSheet values; test behaviour and rendered output
  • Dark mode → set colorScheme.set('dark') per test; reset to 'light' in afterEach
  • Responsive breakpoints → mock Dimensions.get() to simulate different screen sizes
  • Custom theme tokens → verify exact token values (#5aff28 not brand) in style assertions
  • Animated styles → test static NativeWind styles separately from animation state

Treat NativeWind as a styling abstraction — your tests should verify that the right styles are applied for each state, not that the className strings are correct.

Read more