SaaS Onboarding Flow Testing: From Signup to First Value
Onboarding is where SaaS companies win or lose customers before the product has a chance to prove its value. A broken signup flow, an email verification link that doesn't work, a workspace setup that fails silently — any of these ends the relationship before it begins.
The problem with onboarding bugs is that they happen to new users, who don't know your product well enough to know what's wrong or who to call. They just leave.
Testing the onboarding flow end-to-end is one of the highest-leverage testing investments you can make.
The Onboarding Testing Surface
A typical SaaS onboarding flow covers:
- Signup — form validation, account creation, duplicate detection
- Email verification — token generation, link delivery, expiry handling
- Workspace/organization setup — first-time configuration, default data
- Invitation flow — inviting team members during or after onboarding
- Guided tour / checklist — step completion tracking, progression
- Activation milestone — reaching "first value" (first project created, first test run, etc.)
Each step has happy paths and failure modes. Test both.
Testing Signup Validation
Start with the basics. Signup validation bugs are embarrassing and common:
describe('Signup form validation', () => {
it('creates account with valid data', async () => {
const response = await request(app)
.post('/api/auth/signup')
.send({
email: 'user@example.com',
password: 'SecureP@ssw0rd',
name: 'Alice Smith',
companyName: 'Example Corp',
});
expect(response.status).toBe(201);
expect(response.body).toHaveProperty('userId');
expect(response.body).toHaveProperty('tenantId');
});
it('rejects duplicate email addresses', async () => {
await createUser({ email: 'existing@example.com' });
const response = await request(app)
.post('/api/auth/signup')
.send({
email: 'existing@example.com',
password: 'SecureP@ssw0rd',
name: 'Bob Jones',
companyName: 'Other Corp',
});
expect(response.status).toBe(409);
expect(response.body.error).toMatch(/already registered/i);
});
it('enforces minimum password requirements', async () => {
const weakPasswords = ['short', '12345678', 'alllowercase', 'ALLUPPERCASE'];
for (const password of weakPasswords) {
const response = await request(app)
.post('/api/auth/signup')
.send({ email: 'new@example.com', password, name: 'Test', companyName: 'Corp' });
expect(response.status).toBe(422);
}
});
it('sanitizes and normalizes email addresses', async () => {
const response = await request(app)
.post('/api/auth/signup')
.send({
email: ' User@EXAMPLE.COM ', // whitespace and mixed case
password: 'SecureP@ssw0rd',
name: 'Alice',
companyName: 'Corp',
});
expect(response.status).toBe(201);
const user = await getUserByEmail('user@example.com');
expect(user).toBeDefined(); // normalized to lowercase
});
it('creates tenant with correct initial plan', async () => {
const response = await request(app)
.post('/api/auth/signup')
.send({
email: 'newuser@example.com',
password: 'SecureP@ssw0rd',
name: 'New User',
companyName: 'New Corp',
});
const tenant = await getTenant(response.body.tenantId);
expect(tenant.plan).toBe('free'); // or whatever your default plan is
expect(tenant.trialEndsAt).toBeDefined();
});
});Testing Email Verification
Email verification is a common source of onboarding bugs. The token can expire, be malformed, or be sent to the wrong address.
describe('Email verification', () => {
let unverifiedUser, verificationToken;
beforeEach(async () => {
const signupResponse = await request(app)
.post('/api/auth/signup')
.send({
email: 'verify@example.com',
password: 'SecureP@ssw0rd',
name: 'Verify User',
companyName: 'Corp',
});
unverifiedUser = signupResponse.body;
verificationToken = await getVerificationToken(unverifiedUser.userId);
});
it('sends verification email on signup', async () => {
const emails = await getEmailsSentTo('verify@example.com');
expect(emails).toHaveLength(1);
expect(emails[0].subject).toMatch(/verify/i);
expect(emails[0].body).toContain(verificationToken);
});
it('verifies email with valid token', async () => {
const response = await request(app)
.get(`/api/auth/verify-email?token=${verificationToken}`);
expect(response.status).toBe(200);
const user = await getUser(unverifiedUser.userId);
expect(user.emailVerified).toBe(true);
});
it('rejects expired verification tokens', async () => {
await expireToken(verificationToken);
const response = await request(app)
.get(`/api/auth/verify-email?token=${verificationToken}`);
expect(response.status).toBe(410); // Gone
expect(response.body.error).toMatch(/expired/i);
});
it('allows resending verification email', async () => {
const response = await request(app)
.post('/api/auth/resend-verification')
.set('Authorization', `Bearer ${unverifiedUser.token}`);
expect(response.status).toBe(200);
const emails = await getEmailsSentTo('verify@example.com');
expect(emails).toHaveLength(2); // original + resend
});
it('blocks access to protected resources until email is verified', async () => {
const response = await request(app)
.get('/api/projects')
.set('Authorization', `Bearer ${unverifiedUser.token}`);
expect(response.status).toBe(403);
expect(response.body.reason).toBe('email_not_verified');
});
});Testing Workspace Setup
After verification, users typically go through workspace configuration. This is where tenant defaults are set:
describe('Workspace setup', () => {
it('completes workspace setup with required fields', async () => {
const response = await request(app)
.post('/api/onboarding/workspace')
.send({
workspaceName: 'Acme Engineering',
industry: 'Software',
teamSize: '10-50',
useCase: 'qa-automation',
})
.set('Authorization', `Bearer ${verifiedUserToken}`);
expect(response.status).toBe(200);
expect(response.body.onboarding.workspaceSetupComplete).toBe(true);
});
it('creates default project during workspace setup', async () => {
await request(app)
.post('/api/onboarding/workspace')
.send({ workspaceName: 'Acme Engineering', teamSize: '1-10' })
.set('Authorization', `Bearer ${verifiedUserToken}`);
const projects = await request(app)
.get('/api/projects')
.set('Authorization', `Bearer ${verifiedUserToken}`);
// Should have a starter project created automatically
expect(projects.body.projects.length).toBeGreaterThan(0);
expect(projects.body.projects[0].name).toMatch(/getting started/i);
});
it('sends workspace setup complete email', async () => {
await request(app)
.post('/api/onboarding/workspace')
.send({ workspaceName: 'Acme Engineering', teamSize: '1-10' })
.set('Authorization', `Bearer ${verifiedUserToken}`);
const emails = await getEmailsSentTo(verifiedUser.email);
const welcomeEmail = emails.find(e => e.subject.match(/welcome/i));
expect(welcomeEmail).toBeDefined();
});
});Testing the Team Invitation Flow
Most SaaS products allow inviting teammates during onboarding:
describe('Team invitation flow', () => {
it('sends invitation emails to invited addresses', async () => {
const response = await request(app)
.post('/api/onboarding/invite')
.send({
invites: [
{ email: 'colleague1@example.com', role: 'member' },
{ email: 'colleague2@example.com', role: 'admin' },
],
})
.set('Authorization', `Bearer ${adminToken}`);
expect(response.status).toBe(200);
expect(response.body.invitesSent).toBe(2);
for (const email of ['colleague1@example.com', 'colleague2@example.com']) {
const emails = await getEmailsSentTo(email);
expect(emails.some(e => e.subject.match(/invited/i))).toBe(true);
}
});
it('invited user can join via invitation link', async () => {
await sendInvite('colleague@example.com', 'member', tenantId);
const inviteToken = await getInviteToken('colleague@example.com');
const response = await request(app)
.post('/api/auth/accept-invite')
.send({
token: inviteToken,
name: 'New Colleague',
password: 'SecureP@ssw0rd',
});
expect(response.status).toBe(201);
// Verify they're in the correct tenant
const newUser = await getUser(response.body.userId);
expect(newUser.tenantId).toBe(tenantId);
expect(newUser.role).toBe('member');
});
it('prevents invited user from joining different tenant', async () => {
const inviteToken = await getInviteToken('colleague@example.com');
// Try to use invite but associate with wrong tenant
const response = await request(app)
.post('/api/auth/accept-invite')
.send({
token: inviteToken,
name: 'Malicious User',
password: 'SecureP@ssw0rd',
tenantId: 'some-other-tenant', // should be ignored
});
const newUser = await getUser(response.body.userId);
expect(newUser.tenantId).toBe(tenantId); // must use invite's tenant
});
it('expires invitation tokens after 7 days', async () => {
const inviteToken = await getInviteToken('colleague@example.com');
await advanceTime(8 * 24 * 60 * 60 * 1000); // 8 days
const response = await request(app)
.post('/api/auth/accept-invite')
.send({ token: inviteToken, name: 'Late User', password: 'SecureP@ssw0rd' });
expect(response.status).toBe(410);
});
});Testing Onboarding Checklist Progress
Many products show a getting-started checklist. Progress must persist correctly:
describe('Onboarding checklist', () => {
it('shows all checklist items as incomplete for new users', async () => {
const response = await request(app)
.get('/api/onboarding/checklist')
.set('Authorization', `Bearer ${newUserToken}`);
const incomplete = response.body.items.filter(i => !i.completed);
expect(incomplete.length).toBe(response.body.items.length);
});
it('marks item complete when user performs the action', async () => {
// Create first project
await request(app)
.post('/api/projects')
.send({ name: 'My First Project' })
.set('Authorization', `Bearer ${newUserToken}`);
const checklist = await request(app)
.get('/api/onboarding/checklist')
.set('Authorization', `Bearer ${newUserToken}`);
const createProjectItem = checklist.body.items.find(
i => i.key === 'create_first_project'
);
expect(createProjectItem.completed).toBe(true);
});
it('checklist progress persists across sessions', async () => {
await completeChecklistItem(newUser.id, 'create_first_project');
// Simulate new session (new token)
const newSession = await loginUser(newUser.email, newUser.password);
const checklist = await request(app)
.get('/api/onboarding/checklist')
.set('Authorization', `Bearer ${newSession.token}`);
const item = checklist.body.items.find(i => i.key === 'create_first_project');
expect(item.completed).toBe(true);
});
});Testing Activation Milestone
Activation is the moment a user reaches "first value" — the event that predicts long-term retention:
describe('Activation milestone', () => {
it('triggers activation event when user reaches first value', async () => {
const events = [];
trackEvent.mockImplementation(event => events.push(event));
// Perform the activation action (e.g., run first test)
await request(app)
.post('/api/tests/run')
.send({ testId: firstTest.id })
.set('Authorization', `Bearer ${newUserToken}`);
const activationEvent = events.find(e => e.name === 'user_activated');
expect(activationEvent).toBeDefined();
expect(activationEvent.userId).toBe(newUser.id);
});
it('sends activation email after first value achieved', async () => {
await runFirstTest(newUser.id);
const emails = await getEmailsSentTo(newUser.email);
const activationEmail = emails.find(e => e.subject.match(/first test|getting started/i));
expect(activationEmail).toBeDefined();
});
it('does not re-trigger activation for subsequent actions', async () => {
await runFirstTest(newUser.id); // triggers activation
await runFirstTest(newUser.id); // should not trigger again
const emails = await getEmailsSentTo(newUser.email);
const activationEmails = emails.filter(e => e.subject.match(/first test/i));
expect(activationEmails).toHaveLength(1); // only once
});
});End-to-End Onboarding Test
Combine all steps into one full-flow test for confidence:
describe('Complete onboarding flow (E2E)', () => {
it('takes a new user from signup to first value', async () => {
// 1. Sign up
const signup = await request(app)
.post('/api/auth/signup')
.send({ email: 'e2e@example.com', password: 'SecureP@ssw0rd', name: 'E2E User', companyName: 'E2E Corp' });
expect(signup.status).toBe(201);
// 2. Verify email
const token = await getVerificationToken(signup.body.userId);
await request(app).get(`/api/auth/verify-email?token=${token}`);
// 3. Login and complete workspace setup
const login = await request(app)
.post('/api/auth/login')
.send({ email: 'e2e@example.com', password: 'SecureP@ssw0rd' });
const userToken = login.body.token;
await request(app)
.post('/api/onboarding/workspace')
.send({ workspaceName: 'E2E Corp', teamSize: '1-10' })
.set('Authorization', `Bearer ${userToken}`);
// 4. Create first project
const project = await request(app)
.post('/api/projects')
.send({ name: 'First Project' })
.set('Authorization', `Bearer ${userToken}`);
expect(project.status).toBe(201);
// 5. Verify activation milestone reached
const user = await getUser(signup.body.userId);
expect(user.activatedAt).toBeDefined();
expect(user.onboardingCompleted).toBe(true);
});
});Key Takeaways
- Test every step of the onboarding flow — each step can fail independently
- Verify email verification covers token expiry, resend, and access blocking for unverified users
- Test invitation flows for both the inviter and the invitee, including token expiry
- Onboarding checklist progress must persist across sessions, not just in memory
- Activation milestone tracking must be idempotent — fire the event once, not on every qualifying action
- Write one full E2E test that covers signup through activation as a regression guard