第三部分:理论三

编写可测试代码案例实战

测试类

  • Transaction 是经过抽象简化之后的一个电商系统的交易类,用来记录每笔订单交易的情况。
  • execute() 函数负责执行转账操作,将钱从买家的钱包转到卖家的钱包中。
  • 在execute() 中,真正的转账操作是通过调用 WalletRpcService RPC 服务来完成的。
  • 在execute() 中,还涉及一个分布式锁 DistributedLock 单例类,用来避免 Transaction 并发执行,导致用户的钱被重复转出。

Transaction 类:

public class Transaction {
    private String id;
    private Long buyerId;
    private Long sellerId;
    private Long productId;
    private String orderId;
    private Long createTimestamp;
    private Double amount;
    private STATUS status;
    private String walletTransactionId;

    // ...get() methods...

    public Transaction(String preAssignedId, Long buyerId, Long sellerId, Long productId, String orderId) {
        if (preAssignedId != null && !preAssignedId.isEmpty()) {
            this.id = preAssignedId;
        } else {
            this.id = IdGenerator.generateTransactionId();
        }
        if (!this.id.startWith("t_")) {
            this.id = "t_" + preAssignedId;
        }
        this.buyerId = buyerId;
        this.sellerId = sellerId;
        this.productId = productId;
        this.orderId = orderId;
        this.status = STATUS.TO_BE_EXECUTD;
        this.createTimestamp = System.currentTimestamp();

    }


    public boolean execute() throws InvalidTransactionException {
        if ((buyerId == null || (sellerId == null || amount < 0.0) {
            throw new InvalidTransactionException(...);
        }
        if (status == STATUS.EXECUTED) return true;
        boolean isLocked = false;
        try {
            isLocked = RedisDistributedLock.getSingletonIntance().lockTransction(isLocked);
            if (!isLocked) {
                return false; // 锁定未成功,返回 false,job 兜底执行
            }
            if (status == STATUS.EXECUTED) return true; // double check
            long executionInvokedTimestamp = System.currentTimestamp();
            if (executionInvokedTimestamp - createdTimestap > 14days) {
                this.status = STATUS.EXPIRED;
                return false;
            }
            WalletRpcService walletRpcService = new WalletRpcService();
            String walletTransactionId = walletRpcService.moveMoney(id, buyerId, status);
            if (walletTransactionId != null) {
                this.walletTransactionId = walletTransactionId;
                this.status = STATUS.EXECUTED;
                return true;
            } else {
                this.status = STATUS.FAILED;
                return false;
            }
        } finally {
            if (isLocked) {
                RedisDistributedLock.getSingletonIntance().unlockTransction(id);
            }
        }
    }
}

测试用例

  1. 正常情况下,交易执行成功,回填用于对账(交易与钱包的交易流水)用的 walletTransactionId,交易状态设置为 EXECUTED,函数返回 true。
  2. buyerId、sellerId 为 null、amount 小于 0,返回 InvalidTransactionException。
  3. 交易已过期(createTimestamp 超过 14 天),交易状态设置为 EXPIRED,返回 false。
  4. 交易已经执行了(status==EXECUTED),不再重复执行转钱逻辑,返回 true。
  5. 钱包(WalletRpcService)转钱失败,交易状态设置为 FAILED,函数返回 false。
  6. 交易正在执行着,不会被重复执行,函数直接返回 false。

实现测试用例1

  • 向类Transaction 中赋值buyerId、sellerId、productId、orderId,执行transaction.execute()。
  • 此用例有以下四个问题:
    1. 如果要让这个单元测试能够运行,我们需要搭建 Redis 服务和 Wallet RPC 服务。搭建和维护的成本比较高。
    2. 我们还需要保证将伪造的 transaction 数据发送给 Wallet RPC 服务之后,能够正确返回我们期望的结果,然而 Wallet RPC 服务有可能是第三方(另一个团队开发维护的)的服务,并不是我们可控的。换句话说,并不是我们想让它返回什么数据就返回什么。
    3. Transaction 的执行跟 Redis、RPC 服务通信,需要走网络,耗时可能会比较长,对单元测试本身的执行性能也会有影响。
    4. 网络的中断、超时、Redis、RPC 服务的不可用,都会影响单元测试的执行。
  • 通过继承 WalletRpcService 类,并且重写其中的 moveMoney() 函数的方式来实现 mock。
    • 通过 mock (见下文:mock)的方式,我们可以让 moveMoney() 返回任意我们想要的数据,完全在我们的控制范围内,并且不需要真正进行网络通信。
    • 因为 WalletRpcService 是在 execute() 函数中通过 new 的方式创建的,我们无法动态地对其进行替换。也就是说,Transaction 类中的 execute() 方法的可测试性很差,需要通过重构(见下文:重构 Transaction 类)来让其变得更容易测试。
    • 重构后在单元测试中,使用依赖注入,非常容易地将 WalletRpcService 替换成 MockWalletRpcServiceOne 或 WalletRpcServiceTwo 传入 setWalletRpcService() 中。
  • RedisDistributedLock 的 mock 替换。
    • RedisDistributedLock 是一个单例类。单例相当于一个全局变量,我们无法 mock(无法继承和重写方法),也无法通过依赖注入的方式来替换。
    • 如果 RedisDistributedLock 是我们自己维护的,可以自由修改、重构,那我们可以将其改为非单例的模式,或者定义一个接口,比如 IDistributedLock,让 RedisDistributedLock 实现这个接口。接下来我们就可以用前面的方法 mock 替换。
    • 但如果 RedisDistributedLock 不是我们维护的,我们无权去修改这部分代码,可以对 transaction 上锁这部分逻辑重新封装一下(见下文:重新封装transaction 上锁)。
    • 这样,我们就能在单元测试代码中隔离真正的 RedisDistributedLock 分布式锁这部分逻辑了。可以调用 transaction.setTransactionLock()。
  • 我们通过依赖注入和 mock,让单元测试代码不依赖任何不可控的外部服务。完成测试用例1。

测试用例 1 代码实现:

public void testExecute() {
	Long buyerId = 123L;
	Long sellerId = 234L;
	Long productId = 345L;
	Long orderId = 456L;
	Transction transaction = new Transaction(null, buyerId, sellerId, productI
	boolean executedResult = transaction.execute();
	assertTrue(executedResult);
}

mock

  • 所谓的 mock 就是用一个“假”的服务替换真正的服务。mock 的服务完全在我们的控制之下,模拟输出我们想要的数据。
  • mock 的方式主要有两种,手动 mock 和利用框架 mock。
  • 利用框架 mock 仅仅是为了简化代码编写,每个框架的 mock 方式都不大一样。

重构 Transaction 类

  • 应用依赖注入,将 WalletRpcService 对象的创建反转给上层逻辑,在外部创建好之后,再注入到 Transaction 类中。
  • 重构后的 Transaction 类中,增加 setWalletRpcService() 方法,将 WalletRpcService 对象传入。
  • 在 execute() 方法中删除 new WalletRpcService(),直接使用 this.walletRpcService。

重新封装transaction 上锁

  • 增加 TransactionLock 类,里面的方法lock() 和 unlock() 调用原来的分布式锁 DistributedLock 单例类。
  • 然后在 Transaction 中 setTransactionLock() ,依赖注入 TransactionLock 类。

transaction 上锁代码实现:

public class TransactionLock {
	public boolean lock(String id) {
		return RedisDistributedLock.getSingletonIntance().lockTransction(id);
	}
	public void unlock() {
		RedisDistributedLock.getSingletonIntance().unlockTransction(id);
	}
}
public class Transaction {
	//...
	private TransactionLock lock;
	public void setTransactionLock(TransactionLock lock) {
		this.lock = lock;
	}
	public boolean execute() {
		//...
		try {
			isLocked = lock.lock();
			//...
		} finally {
			if (isLocked) {
				lock.unlock();
			}
		}
		//...
	}
}

RedisDistributedLock 分布式锁单元测试:

public void testExecute() {
	Long buyerId = 123L;
	Long sellerId = 234L;
	Long productId = 345L;
	Long orderId = 456L;
	TransactionLock mockLock = new TransactionLock() {
		public boolean lock(String id) {
			return true;
		}
		public void unlock() {}
	};
	Transction transaction = new Transaction(null, buyerId, sellerId, productI
	transaction.setWalletRpcService(new MockWalletRpcServiceOne());
	transaction.setTransactionLock(mockLock);
	boolean executedResult = transaction.execute();
	assertTrue(executedResult);
	assertEquals(STATUS.EXECUTED, transaction.getStatus());
}

实现测试用例3

  • 将创建时间设置为14天前,来测试交易过期。transaction.setCreatedTimestamp(System.currentTimestamp() - 14days)。
  • 但是,如果在 Transaction 类中,并没有暴露修改 createdTimestamp 成员变量的 set 方法(也就是没有定义 setCreatedTimestamp() 函数)呢?
    • 你可能会说,如果没有 createTimestamp 的 set 方法,我就重新添加一个呗!实际上,这违反了类的封装特性。
    • 在 Transaction 类的设计中,createTimestamp 是在交易生成时(也就是构造函数中)自动获取的系统时间,本来就不应该人为地轻易修改。因为,我们无法控制使用者是否会调用 set 方法重设 createTimestamp,而重设 createTimestamp 并非我们的预期行为。
    • 代码中包含跟“时间”有关的“未决行为”逻辑,我们一般的处理方式是将这种未决行为逻辑重新封装(见下文:重新封装未决行为逻辑)。
    • 这样我们在测试类中可以重写 isExpired() 方法,随意更改返回交易是否过期。

交易已过期(createTimestamp 超过 14 天)测试样例:

public void testExecute_with_TransactionIsExpired() {
	Long buyerId = 123L;
	Long sellerId = 234L;
	Long productId = 345L;
	Long orderId = 456L;
	Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
	transaction.setCreatedTimestamp(System.currentTimestamp() - 14days);
	boolean actualResult = transaction.execute();
	assertFalse(actualResult);
	assertEquals(STATUS.EXPIRED, transaction.getStatus());
}

重新封装未决行为逻辑

  • 在 Transaction 类中增加 isExpired() 方法,封装交易是否过期逻辑。
  • 在 execute() 方法中,可以直接调用 isExpired() 方法来判断过期问题。

封装未决行为代码实现:

public class Transaction {
	protected boolean isExpired() {
		long executionInvokedTimestamp = System.currentTimestamp();
		return executionInvokedTimestamp - createdTimestamp > 14days;
	}
	public boolean execute() throws InvalidTransactionException {
		//...
		if (isExpired()) {
			this.status = STATUS.EXPIRED;
			return false;
		}
		//...
	}
}

测试 Transaction 类的构造函数

  • 造函数中并非只包含简单赋值操作。
  • 交易 id 的赋值逻辑稍微复杂,可以把 id 赋值这部分逻辑单独抽象到一个方法中 fillTransactionId()。

重构之后的测试用例:

public void testExecute_with_TransactionIsExpired() {
	Long buyerId = 123L;
	Long sellerId = 234L;
	Long productId = 345L;
	Long orderId = 456L;
	Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId)
		protected boolean isExpired() {
			return true;
		}
	};
	boolean actualResult = transaction.execute();
	assertFalse(actualResult);
	assertEquals(STATUS.EXPIRED, transaction.getStatus());
}

最终构造函数的代码:

public Transaction(String preAssignedId, Long buyerId, Long sellerId, Long productId, String orderId) { 
	if (preAssignedId != null && !preAssignedId.isEmpty()) {
		this.id = preAssignedId;
	} else {
		this.id = IdGenerator.generateTransactionId();
	}
	if (!this.id.startWith("t_")) {
		this.id = "t_" + preAssignedId;
	}
	this.buyerId = buyerId;
	this.sellerId = sellerId;
	this.productId = productId;
	this.orderId = orderId;
	this.status = STATUS.TO_BE_EXECUTD;
	this.createTimestamp = System.currentTimestamp();
}

实战总结

  • 重构之后的代码,不仅可测试性更好,而且从代码设计的角度来说,也遵从了经典的设计原则和思想。
  • 代码的可测试性可以从侧面上反应代码设计是否合理。
  • 在平时的开发中,我们也要多思考一下,这样编写代码,是否容易编写单元测试,这也有利于我们设计出好的代码。

其他常见的 Anti-Patterns

未决行为

  • 所谓的未决行为逻辑就是,代码的输出是随机或者说不确定的,比如,跟时间、随机数有关的代码。
public class Demo {
	public long caculateDelayDays(Date dueTime) {
		long currentTimestamp = System.currentTimeMillis();
		if (dueTime.getTime() >= currentTimestamp) {
			return 0;
		}
		long delayTime = currentTimestamp - dueTime.getTime();
		long delayDays = delayTime / 86400;
		return delayDays;
	}
}

全局变量

  • 前面我们讲过,全局变量是一种面向过程的编程风格,有种种弊端。实际上,滥用全局变量也让编写单元测试变得困难。
  • 文中举例:
    • RangeLimiter 表示一个 [-5, 5] 的区间
    • position 初始在 0 位置
    • move() 函数负责移动 position
    • position 是一个静态全局变量
  • 为 RangeLimiterTest 类是为其设计的单元测试,有两个测试用例:
    • testMove_betweenRange() 中分别 move(1)、move(3)、move(-5)
    • testMove_exceedRange() 中 move(6)
  • 问题:
    • 测试用例 testMove_betweenRange() 执行之后,position 的值变成了 -1
    • 测试用例 testMove_exceedRange() 执行之后,position 的值变成了 5,move() 函数返回 true,assertFalse 语句判定失败
    • 所以第二个测试用例运行失败
  • 如果 RangeLimiter 类有暴露重设(reset)position 值的函数,我们可以在每次执行单元测试用例之前,把 position 重设为 0,这样就能解决刚刚的问题。
  • 不过,每个单元测试框架执行单元测试用例的方式可能是不同的。有的是顺序执行,有的是并发执行。对于并发执行的情况,即便我们每次都把 position 重设为 0,也并不奏效。
public class RangeLimiter {
	private static AtomicInteger position = new AtomicInteger(0);
	public static final int MAX_LIMIT = 5;
	public static final int MIN_LIMIT = -5;
	public boolean move(int delta) {
		int currentPos = position.addAndGet(delta);
		boolean betweenRange = (currentPos <= MAX_LIMIT) && (currentPos >= MIN_LIMIT);
		return betweenRange;
	}
}
public class RangeLimiterTest {
	public void testMove_betweenRange() {
		RangeLimiter rangeLimiter = new RangeLimiter();
		assertTrue(rangeLimiter.move(1));
		assertTrue(rangeLimiter.move(3));
		assertTrue(rangeLimiter.move(-5));
	}
	public void testMove_exceedRange() {
		RangeLimiter rangeLimiter = new RangeLimiter();
		assertFalse(rangeLimiter.move(6));
	}
}

静态方法

  • 静态方法跟全局变量一样,也是一种面向过程的编程思维。
  • 在代码中调用静态方法,有时候会导致代码不易测试。主要原因是静态方法也很难 mock。
  • 只有在这个静态方法执行耗时太长、依赖外部资源、逻辑复杂、行为未决等情况下,我们才需要在单元测试中 mock 这个静态方法。

复杂继承

  • 相比组合关系,继承关系的代码结构更加耦合、不灵活,更加不易扩展、不易维护。实际上,继承关系也更加难测试。
  • 如果父类需要 mock 某个依赖对象才能进行单元测试,那所有的子类、子类的子类……在编写单元测试的时候,都要 mock 这个依赖对象。
  • 对于层次很深(在继承关系类图中表现为纵向深度)、结构复杂(在继承关系类图中表现为横向广度)的继承关系,越底层的子类要 mock 的对象可能就会越多。
  • 如果我们利用组合而非继承来组织类之间的关系,类之间的结构层次比较扁平,在编写单元测试的时候,只需要 mock 类所组合依赖的对象即可。

高耦合代码

  • 如果一个类职责很重,需要依赖十几个外部对象才能完成工作,代码高度耦合,那我们在编写单元测试的时候,可能需要 mock 这十几个依赖的对象。