Server-Driven UI Testing Strategies: A Complete Guide
Server-Driven UI changes how you build mobile apps — and how you test them. Here's a comprehensive guide to testing SDUI systems effectively, from unit tests to production monitoring.
Why SDUI Testing Is Different
Traditional mobile testing focuses on static UI that's compiled into the app. With SDUI, your UI is dynamic data — which means you need to test:
- The rendering engine: Does it handle all component types correctly?
- The server responses: Do backends produce valid UI definitions?
- The integration: Does the full stack work together?
- The variants: Do all A/B test variations render properly?
Let's break down each layer and how to test it effectively.
Layer 1: Component Unit Tests
Each SDUI component should have unit tests that verify it renders correctly given valid props. This is identical to standard component testing.
@Test
fun `ProfileCard renders name and avatar correctly`() {
composeTestRule.setContent {
ProfileCard(
props = ProfileCardProps(
name = "Jane Doe",
avatarUrl = "https://example.com/avatar.jpg",
subtitle = "Product Manager"
)
)
}
composeTestRule.onNodeWithText("Jane Doe").assertExists()
composeTestRule.onNodeWithText("Product Manager").assertExists()
composeTestRule.onNodeWithContentDescription("Profile avatar").assertExists()
}
@Test
fun `ProfileCard handles missing avatar gracefully`() {
composeTestRule.setContent {
ProfileCard(
props = ProfileCardProps(
name = "Jane Doe",
avatarUrl = null,
subtitle = null
)
)
}
composeTestRule.onNodeWithText("Jane Doe").assertExists()
// Should show placeholder instead of crashing
composeTestRule.onNodeWithContentDescription("Default avatar").assertExists()
}
The key difference: SDUI components must handle all edge cases gracefully since props come from the server. Test for:
- Missing optional props
- Empty strings and arrays
- Invalid URLs or malformed data
- Extremely long text content
- Null values where objects are expected
Layer 2: Renderer Unit Tests
Your SDUI renderer (the engine that turns JSON into UI) needs its own test suite. Test that it:
- Maps component types to correct implementations
- Passes props correctly
- Handles unknown component types
- Processes nested layouts
@Test
fun `renders Column with nested children`() {
val json = """
{
"type": "Column",
"props": { "spacing": 16 },
"children": [
{ "type": "Text", "props": { "content": "Hello" } },
{ "type": "Text", "props": { "content": "World" } }
]
}
"""
composeTestRule.setContent {
SDUIRenderer.render(json.parseAsComponent())
}
composeTestRule.onNodeWithText("Hello").assertExists()
composeTestRule.onNodeWithText("World").assertExists()
}
@Test
fun `handles unknown component type with fallback`() {
val json = """
{
"type": "FutureComponentNotYetImplemented",
"props": { "data": "test" }
}
"""
composeTestRule.setContent {
SDUIRenderer.render(json.parseAsComponent())
}
// Should render fallback, not crash
composeTestRule.onNodeWithText("Component not available").assertExists()
}
💡 Fallback Strategy
Always have a fallback for unknown components. This lets your backend deploy new component types before the app update rolls out — old app versions just skip them.
Layer 3: Schema Validation Tests
Before your server sends UI definitions to clients, validate them against a schema. This catches issues before they reach users.
import { validateComponent } from './schema-validator';
import { componentSchemas } from './schemas';
describe('Profile Screen Schema', () => {
it('validates correct profile screen structure', () => {
const screen = {
type: 'Screen',
props: { title: 'Profile' },
children: [
{
type: 'ProfileCard',
props: {
name: 'Jane Doe',
avatarUrl: 'https://cdn.example.com/avatar.jpg'
}
}
]
};
expect(validateComponent(screen, componentSchemas)).toBeValid();
});
it('rejects ProfileCard without required name prop', () => {
const invalid = {
type: 'ProfileCard',
props: {
avatarUrl: 'https://cdn.example.com/avatar.jpg'
// missing required 'name'
}
};
const result = validateComponent(invalid, componentSchemas);
expect(result.valid).toBe(false);
expect(result.errors).toContain("ProfileCard.props.name is required");
});
});
Run schema validation in:
- CI/CD: Every PR that changes UI definitions
- Runtime: Before sending responses (development/staging)
- Monitoring: Log validation failures in production
Layer 4: Contract Testing
Contract tests verify that your backend produces UI that your mobile app can actually render. This catches breaking changes before they reach production.
describe('Profile API Contract', () => {
it('returns valid SDUI for profile screen', async () => {
// Record expected interaction
await provider.addInteraction({
state: 'user 123 exists',
uponReceiving: 'a request for profile screen',
withRequest: {
method: 'GET',
path: '/api/screens/profile',
headers: { 'X-User-Id': '123' }
},
willRespondWith: {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
type: 'Screen',
props: like({ title: 'Profile' }),
children: eachLike({
type: string(),
props: like({})
})
}
}
});
// Verify contract
const response = await profileApi.getProfileScreen('123');
expect(response.type).toBe('Screen');
});
});
Contract testing prevents the classic SDUI failure mode: backend deploys a change, mobile app can't render it, users see blank screens.
Layer 5: Snapshot Testing
Snapshot tests capture what your UI looks like and alert you to unintended visual changes. They're especially valuable for SDUI because the same component might render differently based on server data.
@Test
fun `profile screen matches snapshot`() {
val screenJson = loadTestFixture("profile_screen_standard.json")
composeTestRule.setContent {
SDUIRenderer.render(screenJson.parseAsComponent())
}
composeTestRule.onRoot().captureToImage()
.assertAgainstSnapshot("profile_screen_standard")
}
@Test
fun `profile screen handles long name`() {
val screenJson = loadTestFixture("profile_screen_long_name.json")
composeTestRule.setContent {
SDUIRenderer.render(screenJson.parseAsComponent())
}
composeTestRule.onRoot().captureToImage()
.assertAgainstSnapshot("profile_screen_long_name")
}
⚠️ Snapshot Testing Pitfalls
- Don't snapshot components with timestamps or random IDs
- Keep fixture data stable — randomized test data breaks snapshots
- Review snapshot changes carefully before approving
Layer 6: Integration Tests
Integration tests verify the full SDUI stack: API call → JSON parsing → rendering → user interactions.
@Test
fun `profile screen loads and handles tap action`() {
// Mock the API response
mockWebServer.enqueue(
MockResponse()
.setBody(loadFixture("profile_with_edit_button.json"))
.setHeader("Content-Type", "application/json")
)
// Launch the SDUI screen
launchActivity(
intent = SDUIActivity.createIntent(
context,
screenPath = "/screens/profile"
)
)
// Wait for load and verify UI
composeTestRule.waitUntil {
composeTestRule.onNodeWithText("Jane Doe").isDisplayed()
}
// Test action handling
composeTestRule.onNodeWithText("Edit Profile").performClick()
// Verify navigation occurred
intended(hasComponent(EditProfileActivity::class.java.name))
}
Layer 7: A/B Test Coverage
Every experiment variant needs test coverage. If you're A/B testing three button colors, you need tests for all three.
class CheckoutButtonExperimentTest {
@Test
fun `variant A - green button renders correctly`() {
mockExperimentService.setVariant("checkout_button_color", "green")
val screen = loadScreen("/screens/checkout")
composeTestRule.setContent { SDUIRenderer.render(screen) }
composeTestRule.onNodeWithText("Complete Purchase")
.assertBackgroundColor(Color.Green)
}
@Test
fun `variant B - blue button renders correctly`() {
mockExperimentService.setVariant("checkout_button_color", "blue")
val screen = loadScreen("/screens/checkout")
composeTestRule.setContent { SDUIRenderer.render(screen) }
composeTestRule.onNodeWithText("Complete Purchase")
.assertBackgroundColor(Color.Blue)
}
@Test
fun `control - default button renders correctly`() {
mockExperimentService.setVariant("checkout_button_color", "control")
val screen = loadScreen("/screens/checkout")
composeTestRule.setContent { SDUIRenderer.render(screen) }
composeTestRule.onNodeWithText("Complete Purchase")
.assertBackgroundColor(Color.Gray)
}
}
Automate variant coverage by generating test cases from your experiment config:
// Auto-generate tests for all experiment variants
experiments.forEach { experiment ->
experiment.variants.forEach { variant ->
dynamicTest("${experiment.name} - ${variant.name}") {
testVariantRenders(experiment, variant)
}
}
}
Layer 8: E2E Tests
End-to-end tests verify the complete user journey across multiple SDUI screens. Use tools like Maestro, Appium, or Detox.
# Full checkout flow E2E test
appId: com.example.app
---
- launchApp
- tapOn: "Shop"
- tapOn: "Running Shoes"
- tapOn: "Add to Cart"
- tapOn: "Cart"
- assertVisible: "Running Shoes"
- tapOn: "Checkout"
- inputText:
id: "shipping_address"
text: "123 Main St"
- tapOn: "Complete Purchase"
- assertVisible: "Order Confirmed"
E2E tests for SDUI should be outcome-focused, not implementation-focused. Test "user can complete checkout" not "screen renders exact JSON structure."
Testing Pyramid for SDUI
Here's how we recommend distributing your test effort:
| Layer | Quantity | Speed | Confidence |
|---|---|---|---|
| Component Unit Tests | Many (100s) | Fast (<1ms each) | Component correctness |
| Renderer Unit Tests | Moderate (50-100) | Fast (<10ms each) | Engine correctness |
| Schema Validation | Automated (all definitions) | Fast (<1ms each) | Data validity |
| Contract Tests | Per endpoint (10-30) | Medium (~100ms) | API compatibility |
| Snapshot Tests | Key screens (20-50) | Slow (~1s each) | Visual regression |
| Integration Tests | Critical paths (10-20) | Slow (~5s each) | Stack integration |
| E2E Tests | User journeys (5-10) | Very slow (~30s+) | Full flow validation |
Production Monitoring
Testing doesn't stop at deployment. Monitor these SDUI-specific metrics in production:
- Render success rate: % of screens that render without error
- Unknown component rate: How often apps encounter components they don't know
- Parse failure rate: JSON parsing errors
- Screen load time: API call + render time
- Fallback render rate: How often fallback UI is shown
// Alert if render success drops below 99.5%
if (metrics.renderSuccessRate < 0.995) {
alert.critical(
title = "SDUI Render Failures Elevated",
message = "Success rate: ${metrics.renderSuccessRate * 100}%",
runbook = "runbooks/sdui-render-failures.md"
)
}
// Alert on unknown component spike
if (metrics.unknownComponentRate > 0.01) {
alert.warning(
title = "Unknown SDUI Components Detected",
message = "Rate: ${metrics.unknownComponentRate * 100}%",
components = metrics.unknownComponents.take(10)
)
}
Key Takeaways
- Test at every layer — Component, renderer, schema, contract, integration, E2E
- Validate schemas in CI — Catch invalid UI before it ships
- Contract test your APIs — Prevent backend/mobile mismatches
- Cover all experiment variants — No blind spots in A/B tests
- Monitor production — Testing doesn't end at deployment
SDUI adds complexity, but with the right testing strategy, you get confidence in your dynamic UI system — and the velocity benefits are worth it.
Related Articles
Building a Server-Driven UI System?
Pyramid provides the infrastructure for mature mobile teams to implement SDUI with their existing component libraries. Built-in schema validation and testing utilities included.
Join the Waitlist