• 12 min read

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:

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.

ProfileCard.test.kt
@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:

Layer 2: Renderer Unit Tests

Your SDUI renderer (the engine that turns JSON into UI) needs its own test suite. Test that it:

SDUIRenderer.test.kt
@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.

SchemaValidation.test.ts
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:

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.

Contract Test (Pact-style)
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.

SnapshotTest.kt
@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

Layer 6: Integration Tests

Integration tests verify the full SDUI stack: API call → JSON parsing → rendering → user interactions.

IntegrationTest.kt
@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.

ExperimentVariantTests.kt
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.

checkout_flow.yaml (Maestro)
# 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:

Production Monitoring Alerts
// 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

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