← 中文资源

简体中文版本

探索式测试(Exploratory Testing)

探索式测试把学习、测试设计与执行同时进行,用章程和时间盒在脚本化测试无法预料的区域持续探查产品行为。

阅读英文原文

边学习边测试

探索式测试把测试设计视为执行过程中发生的活动。测试人员形成关于产品行为的假设,尝试它,观察结果,并立即调整下一个实验。这与预先固定步骤的脚本化测试相反。其价值恰好出现在没人写过脚本的地方:异常操作序列、被中断的流程、权限边界和令人困惑的状态。

结构避免随机

好的探索是有纪律的。章程给出任务,例如“探查部分扣款后的退款”;时间盒通常为六十到九十分钟;笔记记录尝试了什么、观察到了什么、还有哪些未探索。基于会话的测试管理把会话汇总起来,使覆盖可见,并能通过复盘质疑假设。没有结构,探索只会变成轶事而非证据。

技能与证据

测试人员需要产品知识、技术好奇心和识别预言的能力:在这里什么算错误。证据很重要,要捕获请求、截图、日志和触发缺陷的精确序列。可复现的路径才能带来修复。把探索会话与可观测性工具结合,能让测试人员看到界面隐藏的服务端影响。

何时使用与局限

新功能、模糊需求、第三方集成以及用户可能行为异常的区域都适合探索。它补充自动化:与脚本化回归测试相比,探索能发现未知的未知,但覆盖不易重复,因此有价值的发现应转化为自动化检查。探索无法低成本覆盖数千条回归,结果高度依赖测试人员水平,也不能替代负载、安全或契约测试。例如,一次会话可能发现令人困惑的错误提示,却不会发现持续流量下的内存泄漏。

代码示例

from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Protocol


class Clock(Protocol):
    def now(self) -> datetime: ...


class SystemClock:
    def now(self) -> datetime:
        return datetime.now(timezone.utc)


class FakeClock:
    def __init__(self, start: datetime) -> None:
        if start.tzinfo is None:
            raise ValueError("start must be timezone aware")
        self._current = start

    def now(self) -> datetime:
        return self._current

    def advance(self, seconds: float) -> None:
        self._current += timedelta(seconds=seconds)


@dataclass
class CachedValue:
    clock: Clock
    ttl: timedelta
    value: str | None = None
    expires_at: datetime | None = None

    def get(self) -> str | None:
        now = self.clock.now()
        if self.value is None or self.expires_at is None or now >= self.expires_at:
            return None
        return self.value

    def put(self, value: str) -> None:
        self.value = value
        self.expires_at = self.clock.now() + self.ttl


def main() -> None:
    clock = FakeClock(datetime(2026, 10, 21, 12, 0, tzinfo=timezone.utc))
    cache = CachedValue(clock=clock, ttl=timedelta(seconds=30))
    cache.put("fresh")
    print("immediately:", cache.get())
    clock.advance(29)
    print("after 29s:", cache.get())
    clock.advance(2)
    print("after 31s:", cache.get())
    print("system clock tz:", SystemClock().now().tzinfo)


if __name__ == "__main__":
    main()
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;

#[derive(Clone)]
struct FakeClock {
    millis: Arc<AtomicU64>,
}

impl FakeClock {
    fn new() -> Self {
        Self {
            millis: Arc::new(AtomicU64::new(0)),
        }
    }

    fn now(&self) -> Duration {
        Duration::from_millis(self.millis.load(Ordering::SeqCst))
    }

    fn advance(&self, delta: Duration) {
        self.millis
            .fetch_add(delta.as_millis() as u64, Ordering::SeqCst);
    }
}

struct Token {
    expires_at: Duration,
}

impl Token {
    fn issue(clock: &FakeClock, ttl: Duration) -> Self {
        Self {
            expires_at: clock.now() + ttl,
        }
    }

    fn expired(&self, clock: &FakeClock) -> bool {
        clock.now() >= self.expires_at
    }
}

fn main() {
    let clock = FakeClock::new();
    let token = Token::issue(&clock, Duration::from_secs(60));
    clock.advance(Duration::from_secs(59));
    println!("expired at 59s: {}", token.expired(&clock));
    clock.advance(Duration::from_secs(1));
    println!("expired at 60s: {}", token.expired(&clock));
}