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-expoand the NativeWind Babel plugin transformclassNameprops- The resulting styles are plain React Native
StyleSheetobjects - RNTL's
toHaveStyle()matcher works against these computed styles
npm install nativewind
npm install -D nativewind/babelbabel.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 testsTesting 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;
fontScaleaffects 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'inafterEach - Responsive breakpoints → mock
Dimensions.get()to simulate different screen sizes - Custom theme tokens → verify exact token values (
#5aff28notbrand) 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.