Testing Zustand Stores Against Interfaces, Not Implementations
In my previous previous article, I showed how to test a concrete IDataStore implementation that uses @react-native-async-storage/async-storage
. But what about the code that consumes this interface? Today I want to share how I test my Zustand store to ensure it works correctly with any storage implementation—whether it’s AsyncStorage, SQLite, or a future web API.
The beauty of this approach is that once you have a well-tested interface like IDataStore, testing the consumers becomes remarkably straightforward. You’re no longer testing against a specific storage implementation, but against a contract.
The Before: Testing Through AsyncStorage
Initially, my Zustand store tests looked like this:
// Testing the store via AsyncStorage mocks
const mockedAsyncStorage = AsyncStorage as jest.Mocked<typeof AsyncStorage>;
describe('addDiveSite action', () => {
it('should save a new dive site', async () => {
// Complex mock setup for AsyncStorage
mockedAsyncStorage.getItem
.mockResolvedValueOnce(JSON.stringify([]))
.mockResolvedValueOnce(JSON.stringify(expectedArray));
await useDiveSiteStore.getState().actions.addDiveSite(newSite);
// Testing low-level storage details
expect(mockedAsyncStorage.setItem).toHaveBeenCalledWith(
SPOTS_STORAGE_KEY,
JSON.stringify(expectedArray)
);
});
});
These tests worked, but they were tightly coupled to AsyncStorage. If I wanted to test with a SQLite implementation, I’d have to rewrite all the mocks.
The After: Testing Against the Interface
Here’s the same test after refactoring to use dependency injection:
describe('addDiveSite action', () => {
it('should save a new dive site', async () => {
// Simple mock that implements IDataStore
const mockDataStore = {
saveDiveSite: jest.fn().mockResolvedValue('new-id'),
getDiveSites: jest.fn().mockResolvedValue([expectedSite]),
};
const store = createDiveSiteStore({ dataStore: mockDataStore });
await store.getState().actions.addDiveSite(newSite);
// Testing the interface contract
expect(mockDataStore.saveDiveSite).toHaveBeenCalledWith(newSite);
expect(store.getState().diveSites).toEqual([expectedSite]);
});
});
The difference is striking. The test now verifies that the store correctly uses the IDataStore interface, without caring about the underlying implementation.
Why This Matters for Real Apps
This approach pays off in several ways:
Future-proofing: When I eventually add a SQLite implementation (DiveSiteSQLite.ts), my store tests won’t change. They’ll work with any class that implements IDataStore.
Testing flexibility: I can create specialized mocks for different scenarios—slow networks, empty states, error conditions—without touching actual storage.
Component testing: UI components can be tested with custom store instances:
// Test with empty state
const emptyStore = createDiveSiteStore({
dataStore: mockEmptyDataStore
});
// Test with loading state
const loadingStore = createDiveSiteStore({
dataStore: mockSlowDataStore
});
render(<DiveSiteList store={emptyStore} />);
It’s About Contracts, Not Implementations
What I love about this pattern is how it changes the testing mindset. Instead of asking “does this work with AsyncStorage?”, I’m asking “does this correctly use the storage interface?”
The tests verify that the store:
Calls the right methods with the right parameters
Handles the responses correctly
Updates its state appropriately
Even for a simple app that will only ever use one storage implementation, the long-term benefits are clear. The tests are more focused, and actually test what matters—the store’s logic, not the storage layer’s implementation.
Most importantly tests for components that build on using the storage are much faster to write and easier to think at.
What do you think? Have you used similar patterns in your Zustand or Redux stores? How do you handle testing against different data sources? I’d love to hear your thoughts in the comments!