Every independent choice can multiply the work.
A release engineer is preparing a customer portal update. The service supports three clients (web, iOS, Android), three plans (free, pro, enterprise), two regions (US, EU), two login methods (password, SSO), and a binary export flag (off, on). A test configuration chooses one value from each dimension.
The question is not “How many test cases do we usually write?” It is “How many distinct configurations are in scope if every choice can combine with every other choice?” That count is the size of the exhaustive test space. It gives the team a baseline before it decides whether exhaustive execution is affordable.
- Client
- Web · iOS · Android
- Plan
- Free · Pro · Enterprise
- Region
- US · EU
- Login
- Password · SSO
- Export
- Off · On
The product rule turns a list of choices into a total.
If a configuration makes one choice from each dimension, and all choices are initially
allowed to combine, multiply the number of values: 3 × 3 × 2 × 2 × 2 = 72.
This is the product rule. For each of the 3 clients there are 3 plans;
for each client-plan pair there are 2 regions; and so on. Each new independent choice
multiplies the number of partial configurations already counted.
3 clients × 3 plans × 2 regions × 2 logins × 2 flag states = 72
The multiplication assumes the dimensions are separate choices and every value can be paired with every value in the next dimension. “Independent” here means combinatorially unrestricted; it does not claim that user behavior, failures, or measured outcomes are statistically independent. Compatibility rules will change the count in the next step.
Some test tasks instead ask how many ways to select or order items. If a
team chooses 2 providers from 4 for a failover pair and order does not matter, it uses a
combination: C(4, 2) = 4! ÷ (2! × 2!) = 6. If primary and standby roles
matter, there are 4 × 3 = 12 ordered pairs (a permutation). Decide whether “first”
and “second” represent distinct roles before choosing the formula.
| After adding | Choices so far | Partial count |
|---|---|---|
| Client | 3 | 3 |
| Plan | 3 × 3 | 9 |
| Region | 9 × 2 | 18 |
| Login | 18 × 2 | 36 |
| Export flag | 36 × 2 | 72 |
Compatibility rules remove cases; they do not justify guessing.
The product count includes combinations the product does not permit. For this example, SSO is available only on Pro and Enterprise plans, and export is available only in the web client. Treat each as an explicit constraint, then count the remaining cases by plan and client rather than subtracting a vague percentage.
First apply the SSO rule. The Free plan has one valid login value; Pro and Enterprise each
have two. Across 3 clients, 2 regions, and 2 export states, that gives (1 + 2 + 2) × 3 × 2 × 2 = 60. Equivalently, 12 of the original 72 rows are
invalid: Free + SSO for each client, region, and export state.
Now apply the web-only export rule. For each mobile client, there are 5 allowed plan-login
combinations and 2 regions; the enabled-export state is invalid, removing 5 × 2 = 10 rows per mobile client. Remove 20 from 60 and the valid exhaustive
space is 40. You can also check directly: web has 5 × 2 × 2 = 20 valid rows, and each mobile client has 5 × 2 × 1 = 10, for 20 + 10 + 10 = 40.
| Step | Calculation | Count left |
|---|---|---|
| All values unrestricted | 3 × 3 × 2 × 2 × 2 | 72 |
| SSO only for paid plans | (1 + 2 + 2) × 3 × 2 × 2 | 60 |
| Export only on web | Web: 20 · iOS: 10 · Android: 10 | 40 |
Testing every row is one choice, not the only defensible plan.
If running all 40 valid configurations is affordable and the release risk warrants it, exhaustive coverage is the clearest plan: every modeled row gets a test. If that is too slow or expensive, a team may use pairwise coverage: choose a smaller set of valid configurations so every feasible pair of values across every pair of dimensions appears at least once.
Why pairwise? It gives the team a systematic baseline for interactions between two choices—for example, EU + SSO or Android + Pro—while using fewer rows than the full product. The constraints matter: a pair that cannot occur, like Free + SSO, is not a feasible obligation. A pairwise generator must understand the same constraints as the application.
Pairwise coverage does not guarantee that every bug is found, or even that every three-way interaction is tested. A failure may require a particular combination of client, plan, and region; it may depend on a migration sequence, stale cache, data shape, or load that a static configuration row does not express. Use risk analysis and production evidence to decide where to add targeted cases, and retain end-to-end, boundary, and negative tests where they matter.
The word combination also appears in a precise counting formula. Choose k unordered items from n with C(n,k) = n! / (k!(n-k)!). A test
matrix usually uses the product rule because it selects one value from each named factor.
Use the combination formula for a separate subset-selection question; use permutations
when order or roles change the outcome.
Enumerate the valid rows and keep the rules visible.
The examples build the five dimensions, evaluate the two compatibility constraints, and count the rows that remain. Trace one rejected case—Free + SSO—and one rejected case—iOS + export enabled—through the predicate. Both languages report 72 raw combinations and 40 valid configurations.
Both implementations enumerate the same illustrative feature matrix.
const clients = ['web', 'ios', 'android'] as const;
const plans = ['free', 'pro', 'enterprise'] as const;
const regions = ['us', 'eu'] as const;
const logins = ['password', 'sso'] as const;
const exportEnabled = [false, true] as const;
let raw = 0;
let rejectedBySso = 0;
let rejectedByExport = 0;
let valid = 0;
for (const client of clients) {
for (const plan of plans) {
for (const region of regions) {
for (const login of logins) {
for (const hasExport of exportEnabled) {
raw += 1;
if (plan === 'free' && login === 'sso') {
rejectedBySso += 1;
continue;
}
if (client !== 'web' && hasExport) {
rejectedByExport += 1;
continue;
}
valid += 1;
}
}
}
}
}
console.log({ raw, rejectedBySso, rejectedByExport, valid });
package main
import "fmt"
func main() {
clients := []string{"web", "ios", "android"}
plans := []string{"free", "pro", "enterprise"}
regions := []string{"us", "eu"}
logins := []string{"password", "sso"}
exportStates := []bool{false, true}
raw, rejectedBySSO, rejectedByExport, valid := 0, 0, 0, 0
for _, client := range clients {
for _, plan := range plans {
for regionIndex := 0; regionIndex < len(regions); regionIndex++ {
for _, login := range logins {
for _, hasExport := range exportStates {
raw++
if plan == "free" && login == "sso" {
rejectedBySSO++
continue
}
if client != "web" && hasExport {
rejectedByExport++
continue
}
valid++
}
}
}
}
}
fmt.Printf("raw=%d rejected_by_sso=%d rejected_by_export=%d valid=%d\n", raw, rejectedBySSO, rejectedByExport, valid)
}
Carry the count into a system with one more interaction.
- Payment method
- Card · bank transfer · wallet
- Currency
- USD · EUR
- Customer state
- New · returning
- Promotion
- Off · on
- Task
- Count the unconstrained space, name a plausible constraint, then propose coverage and its limit.
Show a worked answer
The product rule gives 3 × 2 × 2 × 2 = 24 combinations before constraints. Suppose
promotion is unavailable for bank transfer. There are 2 currencies × 2 customer states = 4
bank-transfer rows with promotion on, so the rule leaves 20 valid configurations.
If exhaustive runs are too costly, select tests so every feasible pair—method with currency, method with customer state, and so on—appears. Add targeted tests for discounts, refunds, and any high-risk workflow. Pairwise coverage does not exercise every four-way combination or prove the payment outcome is correct; assertions and realistic backend behavior still matter.