设计易于测试的软件

16 小时前   出处: Mediam  作/译者:Stefano Dalla Palma/溜的一比

为何测试驱动的设计自然带来更优代码

在我多年的培训经验中,我发现了一个有趣的现象:当开发人员以测试性为设计考量时,他们往往能打造出更优质的代码架构,即便他们并未刻意遵循特定的设计模式。

提及软件测试,许多开发人员常常流露出无奈之情,尤其是单元测试,似乎只是被迫完成的任务。然而,在培训众多工程师后,我观察到,当我们将测试性融入代码设计时,不仅测试工作变得轻松,代码本身的质量也会显著提升。

这并非偶然巧合。代码的可测试性原则与代码的可维护性和可理解性原则本质上是一致的。尽管测试设计这一话题早已被广泛探讨,但我想结合自己一年多以来的培训经验分享一些独特的见解。经过不断打磨内容、幻灯片和代码示例,我已形成一套在两小时培训中既能全面覆盖又能言简意赅的讲解方法。

我的方法与众不同之处在于,聚焦于一个不断演进的实例,展示真实组件在迭代过程中的转变,如何通过微小的设计调整大幅提升测试性。本文将我所学到的内容提炼成一个框架,围绕两大核心概念:可控性与可观测性。这两大支柱不仅助力开发人员编写更优测试,更能设计出更佳的系统架构。

我们将涵盖的内容

  • 理解可控性与可观测性这两大支柱
  • 问题实例:难以测试的一次性密码缓存实现
  • 提升可控性:助力分离依赖的设计模式
  • 拥抱可观测性:使内部状态对测试可见
  • 实用权衡:平衡封装与测试需求
  • 寻找平衡点:何时停止为测试性进行重构
可控性与可观测性:测试的核心基石

可测试的系统本质上是可控且可观察的。这两个概念构成了测试性设计的基石。

可控性是指我们能够操控系统的输入和依赖项,以创建精准的测试场景。它关乎能否获得所有“控制旋钮和杠杆”,以便创建特定的测试条件、可靠地模拟边缘情况以及将组件与其依赖项隔离。没有可控性,测试将变得缓慢、脆弱甚至无法实现。

可观测性则意味着我们能够检查系统的内部状态和行为,无需过多复杂操作。它关乎能否拥有查看系统的“窗口”,以便验证内部状态是否按预期变化、理解特定结果产生的原因以及在测试失败时识别根本原因。没有可观测性,我们只能对代码内部发生的事情进行猜测。

这两大支柱相辅相成——可控性帮助我们设置测试条件,可观测性则让我们验证所发生的情况。正如我们将在示例中看到的,这些方面的改进不仅使测试工作更轻松,还使代码本身更具内在优势。

示例:难以测试的一次性密码(OTP)实现

让我们以常见的安全功能——一次性密码(OTP)系统为例。当你尝试登录银行账户时,他们可能会向你的手机发送一个临时代码,该代码通常在一分钟内有效。这些短生命周期的令牌为敏感操作提供了额外的安全层。

作为开发人员,你需要实现以下功能:

  • 为用户生成唯一的令牌
  • 安全地存储这些令牌
  • 强制执行其短暂的生命周期(通常为一分钟)
  • 在用户尝试使用时验证令牌
  • 确保令牌只能使用一次

出于其短暂的生命周期和性能考虑,这些令牌通常存储在缓存而非数据库中。当令牌被成功使用时,它将从缓存中“消耗”(移除)。如果令牌在未被使用的情况下过期,它应被识别为无效。某些系统还会运行定期清理任务,以从缓存中移除过期令牌。

以下是可能看似合理的初始实现:

java复制

record ShortLivedToken(String tokenValue, long expiryTime) {}

class OneTimePasswordCache {
    private final static long TTL_MS = 60_000; // 生存时间

    private final LinkedHashMap<String, ShortLivedToken> otps = new LinkedHashMap<>();

    public void add(String username, String otp) {
        long exp = System.currentTimeMillis() + TTL_MS;
        otps.put(username, new ShortLivedToken(otp, exp));
    }

    public boolean consume(String username, String otp) {
        ShortLivedToken cachedToken = otps.remove(username);

        // 如果缓存中没有 OTP 或不匹配,则返回 false
        if (cachedToken == null || !otp.equals(cachedToken.tokenValue())) {
            return false;
        }

        return !isTokenExpired(cachedToken);
    }

    private boolean isTokenExpired(ShortLivedToken otp) {
        return System.currentTimeMillis() > otp.expiryTime();
    }
}

在这个实现中,我们使用 LinkedHashMap​ 作为缓存存储用户名 - 令牌对及其过期时间。add​ 方法添加一个具有 60 秒生命周期的令牌,而 consume​ 方法验证并从缓存中移除令牌,确保它只能使用一次。

现在设想自己是一名试图测试此类的开发人员。你会如何测试令牌确实在 60 秒后过期?

存在两个直接问题:

  1. 可观测性有限:isTokenExpired​ 方法是私有的,因此无法直接测试。我们可能会争论是否应将其设为包级私有以便测试,但这并未解决主要问题。
  2. 无法控制时间:即使我们使 isTokenExpired​ 可访问,我们仍然无法在不等待的情况下控制令牌的过期时间。

一种简单的方法可能是添加 sleep​:

java复制

@Test
public void tokenIsExpired_returnTrue() {
    String username = "username";
    String otp = "a42awe";
    OneTimePasswordCache cache = new OneTimePasswordCache();

    cache.add(username, otp);

    Thread.sleep(61_000); // 等待时间超过 TTL
    assertTrue(cache.isTokenExpired(username, otp));
}

但这种方法会导致测试运行时间超过一分钟,且在系统负载变化时可能产生不可靠的结果。

根本问题:对时间机制缺乏控制

我们无法控制时间机制这一依赖项,这使得测试时间依赖行为几乎无法可靠实现。

提升可控性:助力测试的设计模式

那么我们如何修复这个问题呢?让我们像工程师一样思考,重新设计以提升可控性。这归结为三个关键原则:

  1. 分离关注点:“不要害怕创建小型类。”一位同事的这句话改变了我设计组件的方式。如果一个明确的责任可以拥有一个专门的、甚至是微型的组件,那么它就应该如此。
  2. 注入依赖项:我们能注入的组件越多,在测试期间的控制力就越大。我首选通过构造函数进行依赖注入。
  3. 利用模块化设计:将庞大的类分解为更小、更专注的组件,自然会导致更灵活、更易于测试的系统。

这些原则相互强化——分离关注点和利用模块化设计自然会导致更多可注入的依赖项。让我们将这些原则应用于我们的 OTP 实现。

首先,让我们识别原始实现所做的事情:

  • 缓存令牌
  • 验证令牌
  • 处理过期时间(一个隐藏的依赖项!)

原始实现的 add​ 方法负责设置过期时间:

java复制

public void add(String username, String otp) {
  long exp = System.currentTimeMillis() + TTL_MS;
  otps.put(username, new ShortLivedToken(otp, exp));
}

如果没有办法控制 System.currentTimeMillis()​ 这一依赖项,不借助反射等手段的话。但如果我们把生成过期时间的责任提取到一个独立的组件中会怎样呢?

java复制

// 分离时间生成逻辑
class ExpiryTimeGenerator {
    public long nowPlus(long milliseconds) {
        return System.currentTimeMillis() + milliseconds;
    }
}

这看似为一个仅添加当前时间毫秒值的简单功能创建了一个多余的抽象层。但这种简单的分离赋予了我们巨大的测试能力。

现在我们可以重新设计我们的 OTP 类以使用这个新组件:

java复制

class OneTimePasswordCache {
    private final static long TTL_MS = 60_000;
    private final ExpiryTimeGenerator expiryTimeGenerator;
    private final LinkedHashMap<String, ShortLivedToken> otps = new LinkedHashMap<>();

    // 通过构造函数注入依赖
    OneTimePasswordCache(ExpiryTimeGenerator expiryTimeGenerator) {
        this.expiryTimeGenerator = expiryTimeGenerator;
    }

    public void add(String username, String otp) {
        long exp = expiryTimeGenerator.nowPlus(TTL_MS); // 更新后的逻辑
        otps.put(username, new ShortLivedToken(otp, exp));
    }

    // 其他方法...
}

与其直接使用 System.currentTimeMillis()​,我们已将时间管理委托给一个注入的依赖项。

起初,这似乎使一个简单功能的实现复杂化了。为什么要创建一个额外的组件来做我们之前一直在做的事情呢?当我们重新审视我们的测试问题时,答案变得清晰:

java复制

@Test
public void tokenIsExpired_returnTrue() {
    // 使用模拟的时间生成器代替 Thread.sleep()
    ExpiryTimeGenerator expiryTimeGenerator = mock(ExpiryTimeGenerator.class);
    OneTimePasswordCache cache = new OneTimePasswordCache(expiryTimeGenerator);

    // 配置模拟对象以返回一个已过期的时间
    long oneMinuteAgo = System.currentTimeMillis() - 60_000;
    when(expiryTimeGenerator.nowPlus(any())).thenReturn(oneMinuteAgo);

    // 添加一个新令牌到缓存中
    String username = "username";
    String otp = "a42awe";
    cache.add(username, otp);

    // 无需等待即可进行测试 - 令牌已“过期”,因为我们的模拟对象返回了过去的timestamp
    assertTrue(cache.isTokenExpired(username, otp));
}

通过控制时间依赖项来模拟,我们创建了一个已经过期的令牌,而无需等待 60 秒。

是的,测试代码由于模拟设置而变得更长,但好处是巨大的:

  • 测试瞬间完成,无需等待 60 + 秒
  • 再也不用担心因系统时间变化而导致的测试结果不稳定
  • 我们可以测试那些难以复现的边缘情况
  • 依赖项现在是显式的,而非隐藏的

为测试付出的一点点额外复杂性使代码显著更易于测试,同时使依赖项变得明确 —— 这是一个非常值得的权衡。我们不仅确保了令牌添加时过期时间的正确性,还验证了在其他方法(如 consume)中使用 isTokenExpired 方法时的行为。

拥抱可观测性:为测试而设计的可见性

提升了可控性解决了我们部分问题,但我们还需要验证系统内部发生了什么。这就是可观测性发挥作用的地方。

在我们重新设计的 OTP 实现中,我们已经使过期时间变得可控,但我们仍然面临可观测性挑战。让我们检查我们的实现:

java复制

class OneTimePasswordCache {
    // ...

    public void add(String username, String otp) {
        long exp = expiryTimeGenerator.nowPlus(TTL_MS);
        otps.put(username, new ShortLivedToken(otp, exp));
    }

    public boolean consume(String username, String otp) {
        ShortLivedToken cachedToken = otps.remove(username);

        // 如果缓存中没有 OTP 或不匹配,则返回 false
        if (cachedToken == null || !otp.equals(cachedToken.tokenValue())) {
            return false;
        }

        // 检查令牌是否未过期
        return !isTokenExpired(cachedToken);
    }

    private boolean isTokenExpired(ShortLivedToken otp) {/*...*/}
}

当我们尝试测试这个实现时,我们遇到了几个盲点。在测试 add 方法时:

java复制

@Test
void tokenIsAdded() {
    String username = "username";
    String otp = "a42awe";
    cache.add(username, otp);

    // 究竟该如何断言?
}

我们该如何验证令牌确实被添加了?我们没有办法!这就像试图验证一条短信是否已发送,却无法查看收信人的手机或已发送文件夹。你执行了一个操作,但无法确认它是否成功。

同样地,在测试 consume 方法时:

java复制

@Test
void consumeValidToken_returnTrue() {
    String username = "username";
    String otp = "a42awe";
    cache.add(username, otp);

    boolean result = cache.consume(username, otp);
    assertTrue(result);
    // 测试通过了,但为什么会通过?
    // 令牌是否真的从缓存中移除了?
}

我们的测试可能通过了,但它们并未给出全貌。它们就像检查门是否锁上却未验证钥匙是否正确转动了锁芯。

为我们的系统添加“窗口”

为解决这一可观测性问题,我们可以添加方法以暴露内部状态而不改变它:

java复制

class OneTimePasswordCache {
    // 现有的方法...

    // 可观测性方法
    boolean hasTokenFor(String username) {
        return otps.containsKey(username);
    }
}

这样一个简单的方法就像我们系统的“窗口”。它让我们能够查看内部发生的事情而不干扰系统的运行。现在,我们的测试变得更有信息量:

java复制

@Test
void tokenIsAdded() {
    String username = "username";
    String otp = "a42awe";

    assertFalse(cache.hasTokenFor(username)); // 验证初始状态
    cache.add(username, otp);
    assertTrue(cache.hasTokenFor(username)); // 验证令牌已被添加
}

这个测试现在讲述了一个完整的故事:对于给定的用户名,最初没有令牌,我们添加了一个令牌,现在有了令牌。这种清晰性不仅有助于捕获错误,还能为未来的开发人员记录预期行为。

超越二进制响应

另一个可观测性问题是,我们的 consume 方法返回一个简单的布尔值,这隐藏了很多细节。令牌未找到?已过期?还是不匹配?

与其返回布尔值,我们不如返回枚举(或其他对象)以提供更丰富的信息:

java复制

enum ConsumeResult {
   TOKEN_EXPIRED, TOKEN_MISMATCH, TOKEN_NOT_FOUND, TOKEN_CONSUMED
}

class OneTimePasswordCache {
    // 其他方法...

    public ConsumeResult consume(String username, String otp) {
        ShortLivedToken cachedToken = otps.remove(username);

        if (cachedToken == null) {
            return ConsumeResult.TOKEN_NOT_FOUND;
        }

        if (!otp.equals(cachedToken.tokenValue())) {
             return ConsumeResult.TOKEN_MISMATCH;
        }

        if (isTokenExpired(cachedToken)) {
            return ConsumeResult.TOKEN_EXPIRED;
        }

        return ConsumeResult.TOKEN_CONSUMED;
    }
}

这一改变将我们的测试从简单的通过 / 失败检查转变为详细的行文验证:

java复制

@Test
void consumeValidToken_returnsConsumed() {
    String username = "username";
    String otp = "a42awe";
    cache.add(username, otp);

    ConsumeResult result = cache.consume(username, otp);

    assertEquals(ConsumeResult.TOKEN_CONSUMED, result);
    assertFalse(cache.hasTokenFor(username)); // 令牌已被正确移除
}

@Test
void consumeMismatchedToken_returnsMismatch() {
    String username = "username";
    String otp = "a42awe";
    cache.add(username, otp);

    ConsumeResult result = cache.consume(username, "differentOtp");

    assertEquals(ConsumeResult.TOKEN_MISMATCH, result);
    assertFalse(cache.hasTokenFor(username)); // 令牌已被正确移除
}

现在我们确切地知道操作成功或失败的原因,这使我们的测试更加精确和富有信息量。

在可观测性与封装之间取得平衡

添加这些可观测性方法似乎与面向对象编程的一个基本原则相矛盾:封装。难道我们不是在暴露本应隐藏的实现细节吗?

答案并非非黑即白。良好的设计需要在相互竞争的原则之间取得平衡。考虑以下指导原则:

  • 包级私有范围与单独组件:对于测试非公开功能,有两种截然不同的方法:(1)仅通过公开接口进行测试,或(2)将方法设为包级私有以便测试。在我的职业生涯中,我在这些方法之间不断切换。我的经验告诉我,纯粹为了测试而暴露方法会导致测试 - 实现耦合。当实现发生更改时,即使公开行为未变,你也常常需要更新测试。然而,在复杂的现实系统中,实用主义有时会胜出。我的经验法则是:如果一个私有方法所做的工作超出了基本的实用程序工作,并且你有时间,最好将其提取到具有适当公开 API 的独立组件中。否则,使用 @VisibleForTesting 注解或评论来阻止直接使用,并将其设为包级私有,这是一种实用的折中方案。
  • 无副作用:可观测性方法绝不应改变对象的状态。它们应该是纯粹的观察者,仅仅返回信息而不修改任何内容。当一个方法改变状态时,它就不再仅仅是可观测性的,而是核心功能的一部分。
  • 窄化范围:将暴露的内容限制在测试所需的绝对最小范围内。在我们的 OTP 缓存中,我更倾向于使用 hasTokenFor(username) 而不是 getToken(username)。前者确认存在而不暴露敏感数据,仅仅足够验证令牌是否添加,而后者暴露了更多不必要的信息。

目标不是暴露一切,而是提供足够的可见性,使测试全面且清晰,同时维护设计的完整性。

寻找平衡点:何时停止重构

我们对 OTP 实现的探索展示了如何通过提取时间依赖项来提升代码的可测试性。但我们应该将这种方法推进到何种程度呢?让我们考虑我们当前设计中还可以提取的其他内容。

查看我们的 consume 方法,令牌匹配逻辑可能是一个提取候选:

java复制

interface TokenMatcher {
    boolean matches(String providedToken, ShortLivedToken storedToken);
}

class SimpleTokenMatcher implements TokenMatcher {
    public boolean matches(String providedToken, ShortLivedToken storedToken) {
        return providedToken.equals(storedToken.tokenValue());
    }
}

通过这个接口,我们可以在缓存中# 为何测试驱动的设计自然带来更优代码

在我多年的培训经验中,我发现了一个有趣的现象:当开发人员以测试性为设计考量时,他们往往能打造出更优质的代码架构,即便他们并未刻意遵循特定的设计模式。

提及软件测试,许多开发人员常常流露出无奈之情,尤其是单元测试,似乎只是被迫完成的任务。然而,在培训众多工程师后,我观察到,当我们将测试性融入代码设计时,不仅测试工作变得轻松,代码本身的质量也会显著提升。

这并非偶然巧合。代码的可测试性原则与代码的可维护性和可理解性原则本质上是一致的。尽管测试设计这一话题早已被广泛探讨,但我想结合自己一年多以来的培训经验分享一些独特的见解。经过不断打磨内容、幻灯片和代码示例,我已形成一套在两小时培训中既能全面覆盖又能言简意赅的讲解方法。

我的方法与众不同之处在于,聚焦于一个不断演进的实例,展示真实组件在迭代过程中的转变,如何通过微小的设计调整大幅提升测试性。本文将我所学到的内容提炼成一个框架,围绕两大核心概念:可控性与可观测性。这两大支柱不仅助力开发人员编写更优测试,更能设计出更佳的系统架构。

我们将涵盖的内容

  • 理解可控性与可观测性这两大支柱
  • 问题实例 :难以测试的一次性密码缓存实现
  • 提升可控性 :助力分离依赖的设计模式
  • 拥抱可观测性 :使内部状态对测试可见
  • 实用权衡 :平衡封装与测试需求
  • 寻找平衡点 :何时停止为测试性进行重构
可控性与可观测性:测试的核心基石

可测试的系统本质上是可控且可观察的。这两个概念构成了测试性设计的基石。

可控性是指我们能够操控系统的输入和依赖项,以创建精准的测试场景。它关乎能否获得所有 “控制旋钮和杠杆”,以便创建特定的测试条件、可靠地模拟边缘情况以及将组件与其依赖项隔离。没有可控性,测试将变得缓慢、脆弱甚至无法实现。

可观测性则意味着我们能够检查系统的内部状态和行为,无需过多复杂操作。它关乎能否拥有查看系统的 “窗口”,以便验证内部状态是否按预期变化、理解特定结果产生的原因以及在测试失败时识别根本原因。没有可观测性,我们只能对代码内部发生的事情进行猜测。

这两大支柱相辅相成 —— 可控性帮助我们设置测试条件,可观测性则让我们验证所发生的情况。正如我们将在示例中看到的,这些方面的改进不仅使测试工作更轻松,还使代码本身更具内在优势。

示例:难以测试的一次性密码(OTP)实现

让我们以常见的安全功能 —— 一次性密码(OTP)系统为例。当你尝试登录银行账户时,他们可能会向你的手机发送一个临时代码,该代码通常在一分钟内有效。这些短生命周期的令牌为敏感操作提供了额外的安全层。

作为开发人员,你需要实现以下功能:

  • 为用户生成唯一的令牌
  • 安全地存储这些令牌
  • 强制执行其短暂的生命周期(通常为一分钟)
  • 在用户尝试使用时验证令牌
  • 确保令牌只能使用一次

出于其短暂的生命周期和性能考虑,这些令牌通常存储在缓存而非数据库中。当令牌被成功使用时,它将从缓存中 “消耗”(移除)。如果令牌在未被使用的情况下过期,它应被识别为无效。某些系统还会运行定期清理任务,以从缓存中移除过期令牌。

以下是可能看似合理的初始实现:

java复制

record ShortLivedToken(String tokenValue, long expiryTime) {}

class OneTimePasswordCache {
    private final static long TTL_MS = 60_000; // 生存时间

    private final LinkedHashMap<String, ShortLivedToken> otps = new LinkedHashMap<>();

    public void add(String username, String otp) {
        long exp = System.currentTimeMillis() + TTL_MS;
        otps.put(username, new ShortLivedToken(otp, exp));
    }

    public boolean consume(String username, String otp) {
        ShortLivedToken cachedToken = otps.remove(username);

        // 如果缓存中没有 OTP 或不匹配,则返回 false
        if (cachedToken == null || !otp.equals(cachedToken.tokenValue())) {
            return false;
        }

        return !isTokenExpired(cachedToken);
    }

    private boolean isTokenExpired(ShortLivedToken otp) {
        return System.currentTimeMillis() > otp.expiryTime();
    }
}

在这个实现中,我们使用 LinkedHashMap​ 作为缓存存储用户名 - 令牌对及其过期时间。add​ 方法添加一个具有 60 秒生命周期的令牌,而 consume​ 方法验证并从缓存中移除令牌,确保它只能使用一次。

现在设想自己是一名试图测试此类的开发人员。你会如何测试令牌确实在 60 秒后过期?

存在两个直接问题:

  1. 可观测性有限:isTokenExpired​ 方法是私有的,因此无法直接测试。我们可能会争论是否应将其设为包级私有以便测试,但这并未解决主要问题。
  2. 无法控制时间:即使我们使 isTokenExpired​ 可访问,我们仍然无法在不等待的情况下控制令牌的过期时间。

一种简单的方法可能是添加 sleep​ :

java复制

@Test
public void tokenIsExpired_returnTrue() {
    String username = "username";
    String otp = "a42awe";
    OneTimePasswordCache cache = new OneTimePasswordCache();

    cache.add(username, otp);

    Thread.sleep(61_000); // 等待时间超过 TTL
    assertTrue(cache.isTokenExpired(username, otp));
}

但这种方法会导致测试运行时间超过一分钟,且在系统负载变化时可能产生不可靠的结果。

根本问题:对时间机制缺乏控制

我们无法控制时间机制这一依赖项,这使得测试时间依赖行为几乎无法可靠实现。

提升可控性:助力测试的设计模式

那么我们如何修复这个问题呢?让我们像工程师一样思考,重新设计以提升可控性。这归结为三个关键原则:

  1. 分离关注点 : “不要害怕创建小型类。” 一位同事的这句话改变了我设计组件的方式。如果一个明确的责任可以拥有一个专门的、甚至是微型的组件,那么它就应该如此。
  2. 注入依赖项 :我们能注入的组件越多,在测试期间的控制力就越大。我首选通过构造函数进行依赖注入。
  3. 利用模块化设计 :将庞大的类分解为更小、更专注的组件,自然会导致更灵活、更易于测试的系统。

这些原则相互强化 —— 分离关注点和利用模块化设计自然会导致更多可注入的依赖项。让我们将这些原则应用于我们的 OTP 实现。

首先,让我们识别原始实现所做的事情:

  • 缓存令牌
  • 验证令牌
  • 处理过期时间(一个隐藏的依赖项!)

原始实现的 add​ 方法负责设置过期时间:

java复制

public void add(String username, String otp) {
  long exp = System.currentTimeMillis() + TTL_MS;
  otps.put(username, new ShortLivedToken(otp, exp));
}

如果没有办法控制 System.currentTimeMillis()​ 这一依赖项,不借助反射等手段的话。但如果我们把生成过期时间的责任提取到一个独立的组件中会怎样呢?

java复制

// 分离时间生成逻辑
class ExpiryTimeGenerator {
    public long nowPlus(long milliseconds) {
        return System.currentTimeMillis() + milliseconds;
    }
}

这看似为一个仅添加当前时间毫秒值的简单功能创建了一个多余的抽象层。但这种简单的分离赋予了我们巨大的测试能力。

现在我们可以重新设计我们的 OTP 类以使用这个新组件:

java复制

class OneTimePasswordCache {
    private final static long TTL_MS = 60_000;
    private final ExpiryTimeGenerator expiryTimeGenerator;
    private final LinkedHashMap<String, ShortLivedToken> otps = new LinkedHashMap<>();

    // 通过构造函数注入依赖
    OneTimePasswordCache(ExpiryTimeGenerator expiryTimeGenerator) {
        this.expiryTimeGenerator = expiryTimeGenerator;
    }

    public void add(String username, String otp) {
        long exp = expiryTimeGenerator.nowPlus(TTL_MS); // 更新后的逻辑
        otps.put(username, new ShortLivedToken(otp, exp));
    }

    // 其他方法...
}

与其直接使用 System.currentTimeMillis()​,我们已将时间管理委托给一个注入的依赖项。

起初,这似乎使一个简单功能的实现复杂化了。为什么要创建一个额外的组件来做我们之前一直在做的事情呢?当我们重新审视我们的测试问题时,答案变得清晰:

java复制

@Test
public void tokenIsExpired_returnTrue() {
    // 使用模拟的时间生成器代替 Thread.sleep()
    ExpiryTimeGenerator expiryTimeGenerator = mock(ExpiryTimeGenerator.class);
    OneTimePasswordCache cache = new OneTimePasswordCache(expiryTimeGenerator);

    // 配置模拟对象以返回一个已过期的时间
    long oneMinuteAgo = System.currentTimeMillis() - 60_000;
    when(expiryTimeGenerator.nowPlus(any())).thenReturn(oneMinuteAgo);

    // 添加一个新令牌到缓存中
    String username = "username";
    String otp = "a42awe";
    cache.add(username, otp);

    // 无需等待即可进行测试 - 令牌已“过期”,因为我们的模拟对象返回了过去的 timestamp
    assertTrue(cache.isTokenExpired(username, otp));
}

通过控制时间依赖项来模拟,我们创建了一个已经过期的令牌,而无需等待 60 秒。

是的,测试代码由于模拟设置而变得更长,但好处是巨大的:

  • 测试瞬间完成,无需等待 60 + 秒
  • 再也不用担心因系统时间变化而导致的测试结果不稳定
  • 我们可以测试那些难以复现的边缘情况
  • 依赖项现在是显式的,而非隐藏的
  • 为测试付出的一点点额外复杂性使代码显著更易于测试,同时使依赖项变得明确 —— 这是一个非常值得的权衡。我们不仅确保了令牌添加时过期时间的正确性,还验证了在其他方法(如 consume)中使用 isTokenExpired 方法时的行为。

拥抱可观测性:为测试而设计的可见性

提升了可控性解决了我们部分问题,但我们还需要验证系统内部发生了什么。这就是可观测性发挥作用的地方。

在我们重新设计的 OTP 实现中,我们已经使过期时间变得可控,但我们仍然面临可观测性挑战。让我们检查我们的实现:

java复制

class OneTimePasswordCache {
    // ...

    public void add(String username, String otp) {
        long exp = expiryTimeGenerator.nowPlus(TTL_MS);
        otps.put(username, new ShortLivedToken(otp, exp));
    }

    public boolean consume(String username, String otp) {
        ShortLivedToken cachedToken = otps.remove(username);

        // 如果缓存中没有 OTP 或不匹配,则返回 false
        if (cachedToken == null || !otp.equals(cachedToken.tokenValue())) {
            return false;
        }

        // 检查令牌是否未过期
        return !isTokenExpired(cachedToken);
    }

    private boolean isTokenExpired(ShortLivedToken otp) {/*...*/}
}

当我们尝试测试这个实现时,我们遇到了几个盲点。在测试 add 方法时:

java复制

@Test
void tokenIsAdded() {
    String username = "username";
    String otp = "a42awe";
    cache.add(username, otp);

    // 究竟该如何断言?
}

我们该如何验证令牌确实被添加了?我们没有办法!这就像试图验证一条短信是否已发送,却无法查看收信人的手机或已发送文件夹。你执行了一个操作,但无法确认它是否成功。

同样地,在测试 consume 方法时:

java复制

@Test
void consumeValidToken_returnTrue() {
    String username = "username";
    String otp = "a42awe";
    cache.add(username, otp);

    boolean result = cache.consume(username, otp);
    assertTrue(result);
    // 测试通过了,但为什么会通过?
    // 令牌是否真的从缓存中移除了?
}

我们的测试可能通过了,但它们并未给出全貌。它们就像检查门是否锁上却未验证钥匙是否正确转动了锁芯。

为我们的系统添加 “窗口”

为解决这一可观测性问题,我们可以添加方法以暴露内部状态而不改变它:

java复制

class OneTimePasswordCache {
    // 现有的方法...

    // 可观测性方法
    boolean hasTokenFor(String username) {
        return otps.containsKey(username);
    }
}

这样一个简单的方法就像我们系统的 “窗口”。它让我们能够查看内部发生的事情而不干扰系统的运行。现在,我们的测试变得更有信息量:

java复制

@Test
void tokenIsAdded() {
    String username = "username";
    String otp = "a42awe";

    assertFalse(cache.hasTokenFor(username)); // 验证初始状态
    cache.add(username, otp);
    assertTrue(cache.hasTokenFor(username)); // 验证令牌已被添加
}

这个测试现在讲述了一个完整的故事:对于给定的用户名,最初没有令牌,我们添加了一个令牌,现在有了令牌。这种清晰性不仅有助于捕获错误,还能为未来的开发人员记录预期行为。

超越二进制响应

另一个可观测性问题是,我们的 consume 方法返回一个简单的布尔值,这隐藏了很多细节。令牌未找到?已过期?还是不匹配?

与其返回布尔值,我们不如返回枚举(或其他对象)以提供更丰富的信息:

java复制

enum ConsumeResult {
   TOKEN_EXPIRED, TOKEN_MISMATCH, TOKEN_NOT_FOUND, TOKEN_CONSUMED
}

class OneTimePasswordCache {
    // 其他方法...

    public ConsumeResult consume(String username, String otp) {
        ShortLivedToken cachedToken = otps.remove(username);

        if (cachedToken == null) {
            return ConsumeResult.TOKEN_NOT_FOUND;
        }

        if (!otp.equals(cachedToken.tokenValue())) {
             return ConsumeResult.TOKEN_MISMATCH;
        }

        if (isTokenExpired(cachedToken)) {
            return ConsumeResult.TOKEN_EXPIRED;
        }

        return ConsumeResult.TOKEN_CONSUMED;
    }
}

这一改变将我们的测试从简单的通过 / 失败检查转变为详细的行文验证:

java复制

@Test
void consumeValidToken_returnsConsumed() {
    String username = "username";
    String otp = "a42awe";
    cache.add(username, otp);

    ConsumeResult result = cache.consume(username, otp);

    assertEquals(ConsumeResult.TOKEN_CONSUMED, result);
    assertFalse(cache.hasTokenFor(username)); // 令牌已被正确移除
}

@Test
void consumeMismatchedToken_returnsMismatch() {
    String username = "username";
    String otp = "a42awe";
    cache.add(username, otp);

    ConsumeResult result = cache.consume(username, "differentOtp");

    assertEquals(ConsumeResult.TOKEN_MISMATCH, result);
    assertFalse(cache.hasTokenFor(username)); // 令牌已被正确移除
}

现在我们确切地知道操作成功或失败的原因,这使我们的测试更加精确和富有信息量。

在可观测性与封装之间取得平衡

添加这些可观测性方法似乎与面向对象编程的一个基本原则相矛盾:封装。难道我们不是在暴露本应隐藏的实现细节吗?

答案并非非黑即白。良好的设计需要在相互竞争的原则之间取得平衡。考虑以下指导原则:

  1. 包级私有范围与单独组件 :对于测试非公开功能,有两种截然不同的方法:(1)仅通过公开接口进行测试,或(2)将方法设为包级私有以便测试。在我的职业生涯中,我在这些方法之间不断切换。我的经验告诉我,纯粹为了测试而暴露方法会导致测试 - 实现耦合。当实现发生更改时,即使公开行为未变,你也常常需要更新测试。然而,在复杂的现实系统中,实用主义有时会胜出。我的经验法则是:如果一个私有方法所做的工作超出了基本的实用程序工作,并且你有时间,最好将其提取到具有适当公开 API 的独立组件中。否则,使用 @VisibleForTesting 注解或评论来阻止直接使用,并将其设为包级私有,这是一种实用的折中方案。
  2. 无副作用 :可观测性方法绝不应改变对象的状态。它们应该是纯粹的观察者,仅仅返回信息而不修改任何内容。当一个方法改变状态时,它就不再仅仅是可观测性的,而是核心功能的一部分。
  3. 窄化范围 :将暴露的内容限制在测试所需的绝对最小范围内。在我们的 OTP 缓存中,我更倾向于使用 hasTokenFor(username) 而不是 getToken(username)。前者确认存在而不暴露敏感数据,仅仅足够验证令牌是否添加,而后者暴露了更多不必要的信息。

目标不是暴露一切,而是提供足够的可见性,使测试全面且清晰,同时维护设计的完整性。

寻找平衡点:何时停止重构

我们对 OTP 实现的探索展示了如何通过提取时间依赖项来提升代码的可测试性。但我们应该将这种方法推进到何种程度呢?让我们考虑我们当前设计中还可以提取的其他内容。

查看我们的 consume 方法,令牌匹配逻辑可能是一个提取候选:

java复制

interface TokenMatcher {
    boolean matches(String providedToken, ShortLivedToken storedToken);
}

class SimpleTokenMatcher implements TokenMatcher {
    public boolean matches(String providedToken, ShortLivedToken storedToken) {
        return providedToken.equals(storedToken.tokenValue());
    }
}

通过这个接口,我们可以在缓存中注入另一个依赖项:

java复制

class OneTimePasswordCache {
    private final ExpiryTimeGenerator timeGenerator;
    private final TokenMatcher tokenMatcher; // 新的依赖项

    OneTimePasswordCache(ExpiryTimeGenerator timeGenerator, TokenMatcher tokenMatcher) {
        this.timeGenerator = timeGenerator;
        this.tokenMatcher = tokenMatcher;
    }

    // 更新后的 consume 方法
    public ConsumeResult consume(String username, String otp) {
        ShortLivedToken cachedToken = otps.remove(username);

        // ...

        if (!tokenMatcher.matches(otp, cachedToken)) { // 更新后的代码
            return ConsumeResult.TOKEN_MISMATCH;
        }

        // ... 方法的其余部分
    }
}

再次,这是一个微小的改变,它赋予了我们更大的测试能力,特别是用于验证代码的特定路径:

java复制

@Test
void consumeMismatchedToken_returnsMismatch() {
    // 设置带有模拟依赖项
    TokenMatcher mockMatcher = mock(TokenMatcher.class);
    OneTimePasswordCache cache = new OneTimePasswordCache(
        new ExpiryTimeGenerator(), mockMatcher
    );

    // 配置模拟对象以确保令牌验证失败
    when(mockMatcher.matches(anyString(), any())).thenReturn(false);

    // 测试
    String username = "username";
    String otp = "a42awe";
    cache.add(username, otp);

    ConsumeResult result = cache.consume(username, "anyToken");

    // 验证我们得到正确的失败原因
    assertEquals(ConsumeResult.TOKEN_MISMATCH, result);
}

虽然这个测试展示了模拟依赖项的强大力量,但它提出了一个关键问题:组件提取何时才是真正有益的?

在像 OTP 验证这样的功能中,令牌匹配策略可能会演变。从简单的字符串比较开始,可能后来需要支持不同的格式或加密令牌。TokenMatcher 接口为这种演变创造了空间,无需更改消费者代码。

从功能角度来看,系统不同部分可能需要不同的认证策略,我们可以在不修改缓存本身的情况下注入这些策略。TokenMatcher 接口赋予了我们所需的运行时灵活性。

当然,我们应该对这些提取保持深思熟虑。在测试方面,如果现有测试已经通过诸如 add 和 hasTokenFor 之类的方法提供了信心,这种抽象可能并非立即必要。

这就是工程判断发挥作用的地方。我已倾向于为那些可能独立演变的责任创建小型、专注的组件。TokenMatcher 隔离了因安全原因可能改变的功能,使我们的设计不仅可测试,而且能够适应未来的需求。

关键在于有意为之。提取组件不仅是为了测试性,更是因为它们代表了对改善灵活性有意义的关注点分离。当你思考一个提取是否值得时,不仅要考虑今天的测试需求,还要考虑系统明天可能如何演变。

我们的 OTP 实现如何从一个具有隐藏依赖项的紧密耦合设计,演变到一个拥有明确依赖项和增强可观测性的模块化、可测试架构。

通过测试性实现更佳设计

在我的培训课程中,我观察到一个引人入胜的现象:当开发人员以测试性为设计考量时,他们往往能打造出更优质的代码架构,即便他们并未刻意遵循特定的设计模式。

我们所探索的原则 —— 通过关注点分离和依赖注入实现可控性,丰富返回类型,以及战略性可观测性 —— 不仅使代码可测试,还使它:

  • 更加模块化
  • 更易于维护
  • 更具可扩展性
  • 更易于理解

在我们的 OTP 示例中,最初只是一个简单的努力,旨在提升现有代码的可测试性,却导致了一个设计,该设计具有更清晰的责任划分、更明确的依赖项以及更好的诊断能力。这些改进不仅有利于测试,还有利于整个代码库的质量。

当我们面对测试阻力时,我发现与其直接推动测试,不如从这些设计原则入手更为有效。通过改进设计,测试自然变得轻松,开发人员也开始看到测试和设计的双重价值。

因此,下次当你设计一个类或组件时,试着这样做:在编写一行测试代码之前,先问问自己,“我将如何测试这个?” 这个简单的问题可能会引导你走向一个更具可测试性且从根本上更优的设计。

练习:测试缓存容量

作为一个练习,尝试测试缓存的驱逐机制。假设我们的缓存最多持有 100 个令牌,当新令牌到达时自动移除最旧的令牌,如下所示。

java复制

private final LinkedHashMap<String, ShortLivedToken> otps =
    new LinkedHashMap<>(100, 0.75f, true) {
        @Override
        protected boolean removeEldestEntry(Map.Entry<String, ShortLivedToken> eldest) {
            return size() > 100;
        }
    };

你将如何验证:

  • 缓存维持最大 100 个令牌的容量
  • 最旧的令牌首先被驱逐
  • 预期的令牌仍留在缓存中

在没有可观测性方法的情况下,这几乎无法测试。而在拥有这些方法的情况下,测试变得轻而易举。尝试自己编写这些测试。

提示:通常,我们为测试添加的方法最终也会在生产代码中派上用场。例如,hasTokenFor() 方法可能后来被用来检查用户是否需要生成新令牌。那么一个非常简单的 int size() 方法呢?😉


声明:本文为本站编辑转载,文章版权归原作者所有。文章内容为作者个人观点,本站只提供转载参考(依行业惯例严格标明出处和作译者),目的在于传递更多专业信息,普惠测试相关从业者,开源分享,推动行业交流和进步。 如涉及作品内容、版权和其它问题,请原作者及时与本站联系(QQ:1017718740),我们将第一时间进行处理。本站拥有对此声明的最终解释权!欢迎大家通过新浪微博(@测试窝)或微信公众号(测试窝)关注我们,与我们的编辑和其他窝友交流。
/19 人阅读/0 条评论 发表评论

登录 后发表评论
最新文章