React Native Testing
How to keep Playwright E2E useful when React Native Skia renders a canvas
When Skia turns a React Native board into one canvas, Playwright loses the DOM cells it used to inspect. The useful fix is not to disable Skia. It is to test behavior through a narrower surface.
The annoying part of testing a React Native Skia board was not drawing the grid. It was discovering that our E2E suite still expected the old renderer’s DOM shape.
The app looked fine on the device. TypeScript was green. Unit tests were green. A release APK installed. Then Playwright
tried to click #sudoku-cell-1, and the selector was empty because the board had become a canvas. That failure was not a
test nuisance. It was an honest report: our E2E layer was reading the previous DOM shape more than it was exercising the
Sudoku behavior.
We hit that in Daily Sudoku: Offline Puzzle after moving the test environment back to the Skia board renderer. The earlier
choice to test the React Native grid was convenient, but it hid the exact risk we needed to exercise. If production is meant
to trust a canvas board, E2E has to learn how to interact with that board without pretending it is still 81 DOM elements.
So the work was not just “make Skia pass.” It was: keep Skia active in test, give Playwright a truthful way to interact with a canvas board, make moves deterministic, and persist progress without sleeps. A pnpm/GitHub Actions mismatch showed up in the same workstream, but the durable lesson was about canvas observability.
Keep Skia enabled in test
The lazy-looking shortcut was to test the old renderer because it exposes nice DOM nodes. It would have made the E2E suite quiet again. It also would have meant we were no longer testing the renderer we intended to trust.
For a Sudoku board, the old React Native web renderer exposed individual cells with stable IDs and text. Playwright could read a cell, click it, check its class, and assert that the number changed. Once the board moved to Skia, the browser saw a single board surface. The visual state still existed, but not as 81 DOM nodes.
The fix started with a smaller question: which checks describe Sudoku behavior, and which checks describe the old web implementation?
These remained product behavior:
- starting a seeded game;
- selecting an editable cell;
- entering a valid or invalid number;
- counting mistakes;
- winning through the final move;
- seeing the game-over and result flows;
- preserving and clearing saved progress.
These were renderer implementation details:
- finding
#sudoku-cell-1; - reading a cell’s
innerText; - checking Tailwind class names on the cell element;
- using a mutation observer to count text nodes in 81 cells.
Once that split was explicit, the fix got much less dramatic. We did not need a second app, a fake renderer, or a broad test framework. We needed a narrow test-only bridge for the things a browser can no longer inspect when the UI is drawn in Skia.
Add a test bridge, not a second app
The bridge we added is intentionally boring. In the Sudoku game screen, only when the environment is test and the platform
is web, the screen registers a small object on globalThis. It can select a cell, read a cell value, list empty cells, find
empty cells whose solution matches a target number, and persist the current snapshot for deterministic tests. That is all it
is allowed to do.
That sounds suspicious until you compare it with the old setup. The previous test was not using human vision either. It was reading DOM text and IDs. Skia simply removed that private inspection window. The bridge keeps the test at the same behavioral level: choose a cell, press a number, and observe the game through the screen that owns the state.
The important constraints are:
- The bridge is only available in
EXPO_PUBLIC_APP_ENV=test. - It does not create game states that users cannot create.
- It goes through the existing screen handlers where interaction matters.
- It persists snapshots through the same active-game persistence path.
- It does not replace unit tests for game rules.
The last point matters. A bridge should not become a backdoor for proving anything. It is a way for a browser test to interact with a canvas UI. Rule correctness still belongs in domain tests. Navigation, modals, ads, game-over flows, and saved-progress behavior still belong in BDD or E2E tests.
Make moves deterministic
One subtle bug came from a step that sounded harmless: “put the number 1 in an empty cell.” On the old DOM board, the test looped through empty cells until a move worked. That was not elegant, but it usually landed somewhere useful. With Skia, the selection path changed and the autosave timing became easier to outrun. The test could make a move, leave the game, and then expect a saved-progress flow that was never durably created.
The fix was not a longer sleep. The app already knows the solution. In test mode, the bridge can return empty cells whose solution is the digit being requested. The E2E step can then select a valid target and press the keypad button normally.
That keeps the user path honest while removing randomness from the test:
- the board still renders through Skia;
- the number pad still handles the input;
- the game state still updates through normal move logic;
- the active game still persists through normal persistence code.
The only special part is target selection. For E2E, that is acceptable. The test is not trying to prove the puzzle generator can create an empty cell for a digit. It is trying to prove that a real accepted move creates saved progress and unlocks the continue/new-game flow.
Flush persistence explicitly
Autosave is where E2E tests often lie to themselves. They look green because the sleep was long enough on one machine, not because the product promise was explicit.
The Sudoku screen uses a debounce before saving active-game snapshots. That is sensible for the app. It avoids writing to
storage on every tiny state transition. But an E2E test that enters a number and immediately taps Back can outrun the timer.
Adding waitForTimeout(600) looks easy, but it turns the test into a guess.
The better test hook was persistCurrentSnapshot(). It builds the current active-game snapshot through the same screen
function that production uses, checks that the game is not finished and has progress, and flushes it through the real
autosave path.
That makes the test deterministic without changing production behavior. It also says the quiet part out loud: the test needs the progress snapshot to be durable before leaving the screen. It does not need 600 milliseconds of hope.
When a test needs a durable side effect, name that side effect. Do not hide it behind a sleep.
Separate visual assertions from state assertions
Some old assertions were still useful with the DOM renderer and nonsense with Skia. A CSS class like border-danger does not
exist when the wrong-cell highlight is drawn on a canvas. A focus-visible outline test cannot force pseudo classes on a cell
that is no longer a DOM button.
Those tests should not pretend otherwise.
For mixed renderer support, the helper can first ask whether DOM cells exist. If they do, run the DOM-specific check. If they do not, fall back to state-level or flow-level assertions. For example, a game-over test does not need to assert the wrong cell’s CSS class after three mistakes if the game-over modal and the mistake counter prove the behavior.
This is not lowering the bar. It is aligning each assertion with the renderer’s actual surface.
The rule of thumb we now use:
- Assert DOM structure only for the DOM renderer.
- Assert gameplay state through the app when the renderer is a canvas.
- Assert user-visible flow through text, roles, and modals whenever possible.
- Keep a small manual or screenshot smoke layer for visual canvas details.
Canvas rendering changes what is observable. That is not a reason to stop testing behavior. It is a reason to stop pretending the DOM is the product.
Refresh home state before opening drawers
The Skia work also surfaced a product race we could have missed. After factory reset, the database was cleared, but the Home screen still held an in-memory list of active game summaries. A user could open the Play drawer before the focus effect refreshed that list, and the UI would offer an in-progress game that no longer existed.
That was not a flaky wait. It was stale state with a timer-shaped disguise.
The fix was to refresh active game summaries before opening the Play drawer. If the refresh succeeds, the drawer opens with current data. If it fails, the drawer still opens and the error is reported. That is better product behavior and better test behavior.
This is why slow E2E tests are still useful when they are focused. They catch boundaries between screens, persistence, and navigation that unit tests rarely exercise.
Operational note: keep CI on the same package manager
The renderer fixes also exposed a smaller operational issue. It is not the main Skia lesson, but it belongs in the same checklist because a broken install can hide the renderer failure you are trying to debug.
The repository now declares:
{
"packageManager": "pnpm@11.9.0"
}
But the workflows still had:
- uses: pnpm/action-setup@v4
with:
version: 9.15.4
pnpm/action-setup correctly refused to continue because two versions were specified. That protects the project from subtle
lockfile and install differences. The fix was to remove the explicit workflow version and let the action read
packageManager.
One source of truth is dull. That is its main feature.
After a package-manager upgrade, search workflows, docs, Dockerfiles, and setup scripts for the old version. It is easy to
update package.json and forget that CI has its own pinned copy. The build log will tell you eventually. A repository-wide
search is cheaper.
Keep the full suite out of the default loop
The final adjustment was about discipline. The complete Sudoku web E2E suite is intentionally slow. It includes flows that play many games through the UI. Running it casually during every local iteration wastes time and teaches people to interrupt the command halfway through.
The default loop should use tagged or narrowed subsets:
- smoke tests for CI and daily confidence;
- regression tags for the affected flow;
- explicit
--greptargets when debugging one scenario; - scheduled or manually authorized full shards for broad coverage.
That distinction keeps the expensive suite valuable. A slow suite that everyone avoids is worse than a targeted suite people actually run.
What to take away
A Skia migration is not only a rendering change. It changes the shape of what browser tests can observe. If E2E coverage was really asserting user behavior, it can survive that change with small test-only bridges. If it was asserting DOM details, the migration will reveal it.
The fixes that mattered were not clever. Keep Skia enabled in test. Expose a minimal test-only bridge. Pick deterministic valid moves. Persist through the real path instead of sleeping. Refresh screen state before opening UI that depends on storage. Keep CI boring enough that it does not distract from the renderer behavior you are trying to prove.
That is not glamorous work. It is the work that turns a renderer experiment into something you can keep shipping after the demo is over.