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:
- Domain routing — requests to
app.customerdomain.comresolve to the correct tenant configuration - Theme rendering — colors, fonts, logos, and CSS variables apply correctly per tenant
- Configuration isolation — one tenant's custom config doesn't bleed into another's
- 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