← 中文资源

简体中文版本

不稳定测试(Flaky Tests)

不稳定测试在同一份代码上时而通过时而失败,会侵蚀团队对测试套件的信任;正确做法是找出非确定性,而不是反复重跑。

阅读英文原文

为什么重要

一个本地通过、CI 失败,或重跑才通过的测试,会教会团队“红色不等于坏了”。人们开始忽略失败、穿过红色构建合并、反复重跑到变绿。套件失去拦截坏改动能力,真实缺陷藏在噪音中。因此不稳定是交付系统的可靠性问题,而不是小麻烦。

根因

多数不稳定来自未受控的状态或时间。测试共享数据库行、缓存键或全局单例;假设固定时钟、时区或运行时并不保证的顺序;异步工作在断言之后才完成;网络调用间歇超时;随机数据偶尔命中边界;并行执行改变了测试顺序。缺陷可能在测试、生产代码或环境中。

检测

按测试跟踪多次运行结果,而不只看最新一次。标记同一提交上先失败后通过的测试,或在大量运行中失败率非零的测试。用不同种子和并行度循环重跑可疑测试,记录种子、环境和日志以便复现。按浪费的工程时间对不稳定测试排序,让成本可见。

修复与政策

先复现再修复根因:注入时钟、隔离数据、等待真实信号而不是固定 sleep、固定随机种子、消除顺序依赖。如果生产代码存在竞态,就修代码。重试是缓解而不是修复,还会掩盖竞态发生的频率。隔离只能有明确负责人和截止日期。例如,团队可以规定不稳定测试必须在十个工作日内修复或删除;删除一个不检查任何可靠内容的测试,好过保留一个撒谎的信号。

代码示例

package main

import (
	"fmt"
	"strings"
	"testing"
)

func normalizeEmail(raw string) (string, error) {
	email := strings.TrimSpace(strings.ToLower(raw))
	at := strings.IndexByte(email, '@')
	if at <= 0 || at == len(email)-1 || strings.Count(email, "@") != 1 {
		return "", fmt.Errorf("invalid email %q", raw)
	}
	return email, nil
}

func TestNormalizeEmail(t *testing.T) {
	t.Parallel()
	cases := []struct {
		name    string
		input   string
		want    string
		wantErr bool
	}{
		{"lowercases and trims", "  [email protected] ", "[email protected]", false},
		{"rejects missing at", "ada.example.com", "", true},
		{"rejects empty local part", "@example.com", "", true},
		{"rejects double at", "ada@@example.com", "", true},
	}

	for _, testCase := range cases {
		testCase := testCase
		t.Run(testCase.name, func(t *testing.T) {
			t.Parallel()
			got, err := normalizeEmail(testCase.input)
			if testCase.wantErr {
				if err == nil {
					t.Fatalf("expected error, got %q", got)
				}
				return
			}
			if err != nil {
				t.Fatalf("unexpected error: %v", err)
			}
			if got != testCase.want {
				t.Fatalf("got %q, want %q", got, testCase.want)
			}
		})
	}
}

func ExampleNormalizeEmail() {
	email, _ := normalizeEmail("  [email protected] ")
	fmt.Println(email)
	// Output: [email protected]
}
export class FakeClock {
  constructor(start = 0) {
    if (!Number.isFinite(start)) throw new TypeError("start must be a finite number");
    this.now = start;
    this.timers = [];
    this.seq = 0;
  }

  setTimeout(fn, delay) {
    if (!Number.isFinite(delay) || delay <= 0) {
      throw new RangeError("delay must be a positive number");
    }
    const entry = { id: ++this.seq, due: this.now + delay, fn };
    this.timers.push(entry);
    this.timers.sort((a, b) => a.due - b.due || a.id - b.id);
    return entry.id;
  }

  clearTimeout(id) {
    this.timers = this.timers.filter((t) => t.id !== id);
  }

  advance(ms) {
    if (!Number.isFinite(ms) || ms < 0) throw new RangeError("ms must be >= 0");
    const target = this.now + ms;
    for (;;) {
      const next = this.timers[0];
      if (!next || next.due > target) break;
      this.timers.shift();
      this.now = next.due;
      next.fn();
    }
    this.now = target;
  }

  get pending() {
    return this.timers.length;
  }
}