White-Label SaaS Testing: Branded Domains, Theme Overrides, and Custom Config

White-Label SaaS Testing: Branded Domains, Theme Overrides, and Custom Config

White-label SaaS is a forcing function for good multi-tenancy. When a customer runs your product under their own brand, on their own domain, with their own colors and logo, any configuration leak becomes immediately visible. Their customers see a broken experience with the wrong logo, wrong colors, or the wrong company name in emails.

Testing white-label features is more complex than testing standard multi-tenant isolation because it spans multiple layers: DNS/routing, application configuration, visual rendering, and external communications like emails and PDFs.

The White-Label Testing Surface

White-label testing covers four distinct areas:

  1. Domain routing — requests to app.customerdomain.com resolve to the correct tenant configuration
  2. Theme rendering — colors, fonts, logos, and CSS variables apply correctly per tenant
  3. Configuration isolation — one tenant's custom config doesn't bleed into another's
  4. Communication branding — emails, PDFs, and exports use the tenant's brand, not yours

Each area has its own failure modes.

Testing Domain-Based Tenant Resolution

White-label customers use custom domains. Your application must map app.customerdomain.com to the correct tenant configuration.

Unit Testing Domain Resolution

// tenantResolver.test.js
const { resolveTenantFromHost } = require('../services/tenantResolver');

describe('Tenant resolution from host', () => {
  const tenantConfigs = [
    { id: 'tenant-acme', domains: ['app.acme.com', 'acme.yourapp.com'] },
    { id: 'tenant-globex', domains: ['app.globex.io'] },
  ];

  it('resolves tenant from custom domain', async () => {
    const tenant = await resolveTenantFromHost('app.acme.com', tenantConfigs);
    expect(tenant.id).toBe('tenant-acme');
  });

  it('resolves tenant from subdomain on your domain', async () => {
    const tenant = await resolveTenantFromHost('acme.yourapp.com', tenantConfigs);
    expect(tenant.id).toBe('tenant-acme');
  });

  it('returns null for unknown domains', async () => {
    const tenant = await resolveTenantFromHost('unknown.domain.com', tenantConfigs);
    expect(tenant).toBeNull();
  });

  it('is case-insensitive for domain matching', async () => {
    const tenant = await resolveTenantFromHost('APP.ACME.COM', tenantConfigs);
    expect(tenant.id).toBe('tenant-acme');
  });

  it('handles www prefix correctly', async () => {
    // www.app.acme.com should resolve to acme if configured
    const tenant = await resolveTenantFromHost('www.app.acme.com', tenantConfigs);
    // Verify your policy: resolve or reject www prefix
    expect(tenant).toBeDefined(); // or toBeNull() depending on policy
  });
});

Integration Testing Domain Routing

describe('Domain-based routing', () => {
  it('serves correct tenant config for custom domain', async () => {
    const response = await request(app)
      .get('/api/config')
      .set('Host', 'app.acme.com');

    expect(response.status).toBe(200);
    expect(response.body.tenantId).toBe('tenant-acme');
    expect(response.body.branding.companyName).toBe('Acme Corp');
  });

  it('returns 404 for unregistered domains', async () => {
    const response = await request(app)
      .get('/api/config')
      .set('Host', 'notregistered.domain.com');

    expect(response.status).toBe(404);
  });

  it('does not expose other tenant data for cross-domain requests', async () => {
    // Request to acme's domain
    const acmeResponse = await request(app)
      .get('/api/users')
      .set('Host', 'app.acme.com')
      .set('Authorization', `Bearer ${acmeTenantToken}`);

    // Should not contain globex data
    const globexUsers = acmeResponse.body.users.filter(
      u => u.tenantId === 'tenant-globex'
    );
    expect(globexUsers).toHaveLength(0);
  });
});

Testing Theme Override Rendering

Each white-label tenant has custom colors, fonts, and logos. The application must load and apply these per-tenant.

Unit Testing Theme Configuration

// themeService.test.js
describe('Theme configuration loading', () => {
  const acmeTheme = {
    primaryColor: '#E63946',
    secondaryColor: '#457B9D',
    fontFamily: 'Inter, sans-serif',
    logoUrl: 'https://cdn.acme.com/logo.svg',
    faviconUrl: 'https://cdn.acme.com/favicon.ico',
  };

  it('returns tenant theme for registered tenant', async () => {
    await saveTenantTheme('tenant-acme', acmeTheme);
    const theme = await getThemeForTenant('tenant-acme');
    expect(theme).toMatchObject(acmeTheme);
  });

  it('falls back to default theme for unconfigured tenants', async () => {
    const theme = await getThemeForTenant('tenant-no-theme');
    expect(theme.primaryColor).toBe(DEFAULT_THEME.primaryColor);
  });

  it('validates color values are valid CSS hex colors', async () => {
    const invalidTheme = { ...acmeTheme, primaryColor: 'not-a-color' };
    await expect(saveTenantTheme('tenant-acme', invalidTheme))
      .rejects.toThrow('Invalid color format');
  });

  it('generates CSS variables from theme config', () => {
    const cssVars = generateCssVariables(acmeTheme);
    expect(cssVars).toContain('--color-primary: #E63946');
    expect(cssVars).toContain('--color-secondary: #457B9D');
    expect(cssVars).toContain("--font-family: 'Inter', sans-serif");
  });
});

Integration Testing Theme Injection

describe('Theme injection in HTML responses', () => {
  it('injects tenant CSS variables into page HTML', async () => {
    const response = await request(app)
      .get('/')
      .set('Host', 'app.acme.com');

    expect(response.text).toContain('--color-primary: #E63946');
    expect(response.text).toContain('--color-secondary: #457B9D');
  });

  it('sets correct page title from tenant config', async () => {
    const response = await request(app)
      .get('/')
      .set('Host', 'app.acme.com');

    expect(response.text).toContain('<title>Acme Corp — Analytics</title>');
    expect(response.text).not.toContain('<title>YourApp'); // must not show your brand
  });

  it('serves tenant logo from correct URL', async () => {
    const response = await request(app)
      .get('/api/config')
      .set('Host', 'app.acme.com');

    expect(response.body.branding.logoUrl).toBe('https://cdn.acme.com/logo.svg');
    expect(response.body.branding.logoUrl).not.toContain('yourapp.com');
  });
});

Visual Regression Testing for White-Label UIs

Use screenshot comparison to catch visual regressions across tenant themes:

// Using Playwright for visual regression
const { test, expect } = require('@playwright/test');

test.describe('White-label visual rendering', () => {
  test('Acme tenant renders with correct brand colors', async ({ page }) => {
    await page.goto('https://app.acme.com/dashboard');
    await page.waitForLoadState('networkidle');

    // Check that the primary button uses Acme's color
    const buttonColor = await page.$eval(
      '[data-testid="primary-button"]',
      el => getComputedStyle(el).backgroundColor
    );
    expect(buttonColor).toBe('rgb(230, 57, 70)'); // #E63946

    // Visual snapshot for regression detection
    await expect(page).toHaveScreenshot('acme-dashboard.png');
  });

  test('different tenants render different themes without cross-contamination', async ({ browser }) => {
    const acmeContext = await browser.newContext();
    const globexContext = await browser.newContext();

    const acmePage = await acmeContext.newPage();
    const globexPage = await globexContext.newPage();

    await Promise.all([
      acmePage.goto('https://app.acme.com/dashboard'),
      globexPage.goto('https://app.globex.io/dashboard'),
    ]);

    const acmeColor = await acmePage.$eval('body', el =>
      getComputedStyle(el).getPropertyValue('--color-primary')
    );
    const globexColor = await globexPage.$eval('body', el =>
      getComputedStyle(el).getPropertyValue('--color-primary')
    );

    expect(acmeColor).not.toBe(globexColor);
  });
});

Testing Configuration Isolation

Custom configuration (integrations, feature sets, custom fields) must be isolated between tenants:

describe('Tenant configuration isolation', () => {
  beforeEach(async () => {
    await saveTenantConfig('tenant-acme', {
      integrations: { slack: { webhookUrl: 'https://hooks.slack.com/acme' } },
      customFields: [{ name: 'Department', type: 'text' }],
    });

    await saveTenantConfig('tenant-globex', {
      integrations: { slack: { webhookUrl: 'https://hooks.slack.com/globex' } },
      customFields: [{ name: 'Region', type: 'select' }],
    });
  });

  it('returns correct integration config per tenant', async () => {
    const acmeConfig = await getTenantConfig('tenant-acme');
    const globexConfig = await getTenantConfig('tenant-globex');

    expect(acmeConfig.integrations.slack.webhookUrl).toContain('acme');
    expect(globexConfig.integrations.slack.webhookUrl).toContain('globex');
    expect(acmeConfig.integrations.slack.webhookUrl).not.toBe(
      globexConfig.integrations.slack.webhookUrl
    );
  });

  it('custom fields do not bleed between tenants', async () => {
    const acmeResponse = await request(app)
      .get('/api/custom-fields')
      .set('Authorization', `Bearer ${acmeTenantToken}`);

    const fieldNames = acmeResponse.body.fields.map(f => f.name);
    expect(fieldNames).toContain('Department');
    expect(fieldNames).not.toContain('Region'); // Globex's field must not appear
  });
});

Testing Branded Email Delivery

White-label customers expect emails to come from their domain with their brand:

describe('Branded email delivery', () => {
  it('sends from tenant custom domain when configured', async () => {
    await setTenantEmailConfig('tenant-acme', {
      fromDomain: 'acme.com',
      fromName: 'Acme Notifications',
    });

    await triggerWelcomeEmail('user@acme.com', 'tenant-acme');

    const sentEmail = await getLastSentEmail('user@acme.com');
    expect(sentEmail.from).toBe('Acme Notifications <notifications@acme.com>');
    expect(sentEmail.from).not.toContain('yourapp.com');
  });

  it('uses tenant logo in email templates', async () => {
    await triggerWelcomeEmail('user@acme.com', 'tenant-acme');

    const sentEmail = await getLastSentEmail('user@acme.com');
    expect(sentEmail.html).toContain('https://cdn.acme.com/logo.svg');
    expect(sentEmail.html).not.toContain('yourapp-logo.svg');
  });
});

Key Takeaways

  • Test domain resolution with exact domain matching, case variations, and www prefix handling
  • Verify that CSS variables are injected per-tenant and don't cross-contaminate between tenants
  • Use visual regression screenshots to catch theme rendering issues across deployments
  • Test that emails, exports, and other external communications use the tenant's brand
  • Configuration isolation tests must explicitly check that Tenant A's config doesn't appear in Tenant B's API responses
  • Test the fallback to default theme when a tenant hasn't configured custom branding

Read more