learn.lianglianglee.com/专栏/领域驱动设计实践(完)/086 案例 薪资管理系统的测试驱动开发(下).md.html
2022-05-11 19:04:14 +08:00

1328 lines
51 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<!-- saved from url=(0046)https://kaiiiz.github.io/hexo-theme-book-demo/ -->
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=no">
<link rel="icon" href="/static/favicon.png">
<title>086 案例 薪资管理系统的测试驱动开发(下).md.html</title>
<!-- Spectre.css framework -->
<link rel="stylesheet" href="/static/index.css">
<!-- theme css & js -->
<meta name="generator" content="Hexo 4.2.0">
</head>
<body>
<div class="book-container">
<div class="book-sidebar">
<div class="book-brand">
<a href="/">
<img src="/static/favicon.png">
<span>技术文章摘抄</span>
</a>
</div>
<div class="book-menu uncollapsible">
<ul class="uncollapsible">
<li><a href="/" class="current-tab">首页</a></li>
</ul>
<ul class="uncollapsible">
<li><a href="../">上一级</a></li>
</ul>
<ul class="uncollapsible">
<li>
<a href="/专栏/领域驱动设计实践(完)/001 「战略篇」访谈 DDD 和微服务是什么关系?.md.html">001 「战略篇」访谈 DDD 和微服务是什么关系?.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/002 「战略篇」开篇词:领域驱动设计,重焕青春的设计经典.md.html">002 「战略篇」开篇词:领域驱动设计,重焕青春的设计经典.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/003 领域驱动设计概览.md.html">003 领域驱动设计概览.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/004 深入分析软件的复杂度.md.html">004 深入分析软件的复杂度.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/005 控制软件复杂度的原则.md.html">005 控制软件复杂度的原则.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/006 领域驱动设计对软件复杂度的应对(上).md.html">006 领域驱动设计对软件复杂度的应对(上).md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/007 领域驱动设计对软件复杂度的应对(下).md.html">007 领域驱动设计对软件复杂度的应对(下).md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/008 软件开发团队的沟通与协作.md.html">008 软件开发团队的沟通与协作.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/009 运用领域场景分析提炼领域知识(上).md.html">009 运用领域场景分析提炼领域知识(上).md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/010 运用领域场景分析提炼领域知识(下).md.html">010 运用领域场景分析提炼领域知识(下).md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/011 建立统一语言.md.html">011 建立统一语言.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/012 理解限界上下文.md.html">012 理解限界上下文.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/013 限界上下文的控制力(上).md.html">013 限界上下文的控制力(上).md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/014 限界上下文的控制力(下).md.html">014 限界上下文的控制力(下).md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/015 识别限界上下文(上).md.html">015 识别限界上下文(上).md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/016 识别限界上下文(下).md.html">016 识别限界上下文(下).md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/017 理解上下文映射.md.html">017 理解上下文映射.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/018 上下文映射的团队协作模式.md.html">018 上下文映射的团队协作模式.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/019 上下文映射的通信集成模式.md.html">019 上下文映射的通信集成模式.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/020 辨别限界上下文的协作关系(上).md.html">020 辨别限界上下文的协作关系(上).md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/021 辨别限界上下文的协作关系(下).md.html">021 辨别限界上下文的协作关系(下).md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/022 认识分层架构.md.html">022 认识分层架构.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/023 分层架构的演化.md.html">023 分层架构的演化.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/024 领域驱动架构的演进.md.html">024 领域驱动架构的演进.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/025 案例 层次的职责与协作关系(图文篇).md.html">025 案例 层次的职责与协作关系(图文篇).md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/026 限界上下文与架构.md.html">026 限界上下文与架构.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/027 限界上下文对架构的影响.md.html">027 限界上下文对架构的影响.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/028 领域驱动设计的代码模型.md.html">028 领域驱动设计的代码模型.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/029 代码模型的架构决策.md.html">029 代码模型的架构决策.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/030 实践 先启阶段的需求分析.md.html">030 实践 先启阶段的需求分析.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/031 实践 先启阶段的领域场景分析(上).md.html">031 实践 先启阶段的领域场景分析(上).md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/032 实践 先启阶段的领域场景分析(下).md.html">032 实践 先启阶段的领域场景分析(下).md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/033 实践 识别限界上下文.md.html">033 实践 识别限界上下文.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/034 实践 确定限界上下文的协作关系.md.html">034 实践 确定限界上下文的协作关系.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/035 实践 EAS 的整体架构.md.html">035 实践 EAS 的整体架构.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/036 「战术篇」访谈DDD 能帮开发团队提高设计水平吗?.md.html">036 「战术篇」访谈DDD 能帮开发团队提高设计水平吗?.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/037 「战术篇」开篇词:领域驱动设计的不确定性.md.html">037 「战术篇」开篇词:领域驱动设计的不确定性.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/038 什么是模型.md.html">038 什么是模型.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/039 数据分析模型.md.html">039 数据分析模型.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/040 数据设计模型.md.html">040 数据设计模型.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/041 数据模型与对象模型.md.html">041 数据模型与对象模型.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/042 数据实现模型.md.html">042 数据实现模型.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/043 案例 培训管理系统.md.html">043 案例 培训管理系统.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/044 服务资源模型.md.html">044 服务资源模型.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/045 服务行为模型.md.html">045 服务行为模型.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/046 服务设计模型.md.html">046 服务设计模型.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/047 领域模型驱动设计.md.html">047 领域模型驱动设计.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/048 领域实现模型.md.html">048 领域实现模型.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/049 理解领域模型.md.html">049 理解领域模型.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/050 领域模型与结构范式.md.html">050 领域模型与结构范式.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/051 领域模型与对象范式(上).md.html">051 领域模型与对象范式(上).md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/052 领域模型与对象范式(中).md.html">052 领域模型与对象范式(中).md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/053 领域模型与对象范式(下).md.html">053 领域模型与对象范式(下).md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/054 领域模型与函数范式.md.html">054 领域模型与函数范式.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/055 领域驱动分层架构与对象模型.md.html">055 领域驱动分层架构与对象模型.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/056 统一语言与领域分析模型.md.html">056 统一语言与领域分析模型.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/057 精炼领域分析模型.md.html">057 精炼领域分析模型.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/058 彩色 UML 与彩色建模.md.html">058 彩色 UML 与彩色建模.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/059 四色建模法.md.html">059 四色建模法.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/060 案例 订单核心流程的四色建模.md.html">060 案例 订单核心流程的四色建模.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/061 事件风暴与业务全景探索.md.html">061 事件风暴与业务全景探索.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/062 事件风暴与领域分析建模.md.html">062 事件风暴与领域分析建模.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/063 案例 订单核心流程的事件风暴.md.html">063 案例 订单核心流程的事件风暴.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/064 表达领域设计模型.md.html">064 表达领域设计模型.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/065 实体.md.html">065 实体.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/066 值对象.md.html">066 值对象.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/067 对象图与聚合.md.html">067 对象图与聚合.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/068 聚合设计原则.md.html">068 聚合设计原则.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/069 聚合之间的关系.md.html">069 聚合之间的关系.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/070 聚合的设计过程.md.html">070 聚合的设计过程.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/071 案例 培训领域模型的聚合设计.md.html">071 案例 培训领域模型的聚合设计.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/072 领域模型对象的生命周期-工厂.md.html">072 领域模型对象的生命周期-工厂.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/073 领域模型对象的生命周期-资源库.md.html">073 领域模型对象的生命周期-资源库.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/074 领域服务.md.html">074 领域服务.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/075 案例 领域设计模型的价值.md.html">075 案例 领域设计模型的价值.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/076 应用服务.md.html">076 应用服务.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/077 场景的设计驱动力.md.html">077 场景的设计驱动力.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/078 案例 薪资管理系统的场景驱动设计.md.html">078 案例 薪资管理系统的场景驱动设计.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/079 场景驱动设计与 DCI 模式.md.html">079 场景驱动设计与 DCI 模式.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/080 领域事件.md.html">080 领域事件.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/081 发布者—订阅者模式.md.html">081 发布者—订阅者模式.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/082 事件溯源模式.md.html">082 事件溯源模式.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/083 测试优先的领域实现建模.md.html">083 测试优先的领域实现建模.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/084 深入理解简单设计.md.html">084 深入理解简单设计.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/085 案例 薪资管理系统的测试驱动开发(上).md.html">085 案例 薪资管理系统的测试驱动开发(上).md.html</a>
</li>
<li>
<a class="current-tab" href="/专栏/领域驱动设计实践(完)/086 案例 薪资管理系统的测试驱动开发(下).md.html">086 案例 薪资管理系统的测试驱动开发(下).md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/087 对象关系映射(上).md.html">087 对象关系映射(上).md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/088 对象关系映射(下).md.html">088 对象关系映射(下).md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/089 领域模型与数据模型.md.html">089 领域模型与数据模型.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/090 领域驱动设计对持久化的影响.md.html">090 领域驱动设计对持久化的影响.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/091 领域驱动设计体系.md.html">091 领域驱动设计体系.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/092 子领域与限界上下文.md.html">092 子领域与限界上下文.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/093 限界上下文的边界与协作.md.html">093 限界上下文的边界与协作.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/094 限界上下文之间的分布式通信.md.html">094 限界上下文之间的分布式通信.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/095 命令查询职责分离.md.html">095 命令查询职责分离.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/096 分布式柔性事务.md.html">096 分布式柔性事务.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/097 设计概念的统一语言.md.html">097 设计概念的统一语言.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/098 模型对象.md.html">098 模型对象.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/099 领域驱动设计参考过程模型.md.html">099 领域驱动设计参考过程模型.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/100 领域驱动设计的精髓.md.html">100 领域驱动设计的精髓.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/101 实践 员工上下文的领域建模.md.html">101 实践 员工上下文的领域建模.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/102 实践 考勤上下文的领域建模.md.html">102 实践 考勤上下文的领域建模.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/103 实践 项目上下文的领域建模.md.html">103 实践 项目上下文的领域建模.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/104 实践 培训上下文的业务需求.md.html">104 实践 培训上下文的业务需求.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/105 实践 培训上下文的领域分析建模.md.html">105 实践 培训上下文的领域分析建模.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/106 实践 培训上下文的领域设计建模.md.html">106 实践 培训上下文的领域设计建模.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/107 实践 培训上下文的领域实现建模.md.html">107 实践 培训上下文的领域实现建模.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/108 实践 EAS 系统的代码模型.md.html">108 实践 EAS 系统的代码模型.md.html</a>
</li>
<li>
<a href="/专栏/领域驱动设计实践(完)/109 后记:如何学习领域驱动设计.md.html">109 后记:如何学习领域驱动设计.md.html</a>
</li>
</ul>
</div>
</div>
<div class="sidebar-toggle" onclick="sidebar_toggle()" onmouseover="add_inner()" onmouseleave="remove_inner()">
<div class="sidebar-toggle-inner"></div>
</div>
<script>
function add_inner() {
let inner = document.querySelector('.sidebar-toggle-inner')
inner.classList.add('show')
}
function remove_inner() {
let inner = document.querySelector('.sidebar-toggle-inner')
inner.classList.remove('show')
}
function sidebar_toggle() {
let sidebar_toggle = document.querySelector('.sidebar-toggle')
let sidebar = document.querySelector('.book-sidebar')
let content = document.querySelector('.off-canvas-content')
if (sidebar_toggle.classList.contains('extend')) { // show
sidebar_toggle.classList.remove('extend')
sidebar.classList.remove('hide')
content.classList.remove('extend')
} else { // hide
sidebar_toggle.classList.add('extend')
sidebar.classList.add('hide')
content.classList.add('extend')
}
}
function open_sidebar() {
let sidebar = document.querySelector('.book-sidebar')
let overlay = document.querySelector('.off-canvas-overlay')
sidebar.classList.add('show')
overlay.classList.add('show')
}
function hide_canvas() {
let sidebar = document.querySelector('.book-sidebar')
let overlay = document.querySelector('.off-canvas-overlay')
sidebar.classList.remove('show')
overlay.classList.remove('show')
}
</script>
<div class="off-canvas-content">
<div class="columns">
<div class="column col-12 col-lg-12">
<div class="book-navbar">
<!-- For Responsive Layout -->
<header class="navbar">
<section class="navbar-section">
<a onclick="open_sidebar()">
<i class="icon icon-menu"></i>
</a>
</section>
</header>
</div>
<div class="book-content" style="max-width: 960px; margin: 0 auto;
overflow-x: auto;
overflow-y: hidden;">
<div class="book-post">
<p id="tip" align="center"></p>
<div><h1>086 案例 薪资管理系统的测试驱动开发(下)</h1>
<h3>测试驱动开发的过程</h3>
<h4>满足简单设计并编写新的测试</h4>
<p>当代码满足重用性和可读性之后,就应遵循简单设计的第四条原则“若无必要,勿增实体”,不要盲目地考虑为其增加新的软件元素。这时,需要暂时停止重构,编写新的测试。</p>
<p>现在,要测试加班的用例,需提供超过 8 小时的工作时间卡。测试代码已经定义了创建工作时间卡的方法,新测试的需求差异仅在于工作时长,为了测试代码的重用,可以提取 createTimeCards() 方法的参数,允许传入不同的工作时长。因此,新编写的测试如下所示:</p>
<pre><code class="language-java"> @Test
public void should_calculate_payroll_by_work_hours_with_overtime_in_a_week() {
//given
List&lt;TimeCard&gt; timeCards = createTimeCards(9, 7, 10, 10, 8);
Money salaryOfHour = Money.of(10000, Currency.RMB);
HourlyEmployee hourlyEmployee = new HourlyEmployee(timeCards, salaryOfHour);
//when
Payroll payroll = hourlyEmployee.payroll();
//then
assertThat(payroll).isNotNull();
assertThat(payroll.beginDate()).isEqualTo(LocalDate.of(2019, 9, 2));
assertThat(payroll.endDate()).isEqualTo(LocalDate.of(2019, 9, 6));
assertThat(payroll.amount()).isEqualTo(Money.of(465000, Currency.RMB));
}
</code></pre>
<p>提供的工作时间卡包含了加班、正常工作时间、低于正常工作时间三种情况,综合计算钟点工的薪资。运行测试,测试失败。</p>
<h4>让失败的测试刚好通过</h4>
<p>为该测试编写实现。</p>
<p>按照业务规则,加班时间的报酬会按照正常报酬的 1.5 倍进行支付,这就需要 Money 支持对 1.5 的乘法。在最初定义的 Money 类中,使用了 long 类型来代表面值,并以分作为货币单位。原本的 multiply() 方法支持的因数为 int 类型,现在需要改为支持 double 类型。为保证薪资的精确计算,可考虑使用 BigDecimal 来实现,这就需要先修改 Money 类的定义。</p>
<p>这相当于新的测试对原有产品代码提出了新的要求。需要暂时搁置对新测试的实现,而是对已有产品代码按照新的需求进行调整,修改 Money 类的定义,并在修改后运行已有的所有测试,确保这一修改并未破坏原有测试。接下来,实现刚才编写的新测试:</p>
<pre><code class="language-java"> public Payroll payroll() {
int regularHours = timeCards.stream()
.map(tc -&gt; tc.workHours() &gt; 8 ? 8 : tc.workHours())
.reduce(0, (hours, total) -&gt; hours + total);
int overtimeHours = timeCards.stream()
.filter(tc -&gt; tc.workHours() &gt; 8)
.map(tc -&gt; tc.workHours() - 8)
.reduce(0, (hours, total) -&gt; hours + total);
Money regularSalary = salaryOfHour.multiply(regularHours);
// 修改了 multiply() 方法的定义,支持 double 类型
Money overtimeSalary = salaryOfHour.multiply(1.5).multiply(overtimeHours);
Money totalSalary = regularSalary.add(overtimeSalary);
return new Payroll(
settlementPeriod().beginDate,
settlementPeriod().endDate,
totalSalary);
}
</code></pre>
<p>测试通过。</p>
<h4>重构以改进代码的质量</h4>
<p>现在仍然按照简单设计原则消除重复,提高代码可读性。首先,可以提取 8 和 1.5 这样的常量,对代码作微量调整。仔细阅读实现代码对 filter 与 map 函数的调用发现这些函数接收的lambda表达式操作的数据皆为 TimeCard 类所拥有。遵循“信息专家模式”,且遵循对象之间采用行为协作,避免协作对象成为数据提供者,需要将这些表达式提取为方法,然后将提取出来的方法转移到 TimeCard 类:</p>
<pre><code class="language-java">public class TimeCard implements Comparable&lt;TimeCard&gt; {
private static final int MAXIMUM_REGULAR_HOURS = 8;
private LocalDate workDay;
private int workHours;
public TimeCard(LocalDate workDay, int workHours) {
this.workDay = workDay;
this.workHours = workHours;
}
public int workHours() {
return this.workHours;
}
public LocalDate workDay() {
return this.workDay;
}
public boolean isOvertime() {
return workHours() &gt; MAXIMUM_REGULAR_HOURS;
}
public int getOvertimeWorkHours() {
return workHours() - MAXIMUM_REGULAR_HOURS;
}
public int getRegularWorkHours() {
return isOvertime() ? MAXIMUM_REGULAR_HOURS : workHours();
}
}
</code></pre>
<p>这一重构说明只要时刻注意对象之间正确的协作模式就能避免出现贫血模型。领域对象持有的行为并非刻意追求而是通过识别代码坏味道遵循面向对象设计原则逐步形成的。经过如此重构后payroll() 方法的实现为:</p>
<pre><code class="language-java"> public Payroll payroll() {
int regularHours = timeCards.stream()
.map(TimeCard::getRegularWorkHours)
.reduce(0, (hours, total) -&gt; hours + total);
int overtimeHours = timeCards.stream()
.filter(TimeCard::isOvertime)
.map(TimeCard::getOvertimeWorkHours)
.reduce(0, (hours, total) -&gt; hours + total);
Money regularSalary = salaryOfHour.multiply(regularHours);
Money overtimeSalary = salaryOfHour.multiply(OVERTIME_FACTOR).multiply(overtimeHours);
Money totalSalary = regularSalary.add(overtimeSalary);
return new Payroll(
settlementPeriod().beginDate,
settlementPeriod().endDate,
totalSalary);
}
</code></pre>
<p>显然,方法暴露了太多细节,缺乏足够的层次,因而无法清晰表达方法的执行步骤:先计算正常工作小时数的薪资,然后再计算加班小时数的薪资,即可得到该钟点工最终要发放的薪资。通过提取方法就能隐藏细节,使得主方法清晰地体现业务步骤:</p>
<pre><code class="language-java"> public Payroll payroll() {
Money regularSalary = calculateRegularSalary();
Money overtimeSalary = calculateOvertimeSalary();
Money totalSalary = regularSalary.add(overtimeSalary);
return new Payroll(
settlementPeriod().beginDate,
settlementPeriod().endDate,
totalSalary);
}
</code></pre>
<p>这种将一个相对较长的主方法通过提取方法清晰表达其执行过程的模式,被 Kent Beck 称之为“组合方法”模式。他在著作 <em>Smalltalk Best Practice Patterns</em> 中阐释了这一模式:</p>
<ul>
<li>把程序划分为方法,每个方法执行一个可识别的任务。</li>
<li>让一个方法中的所有操作处于相同的抽象层。</li>
<li>这会自然地产生包含许多小方法的程序,每个方法只包含少量代码。</li>
</ul>
<p>提取方法是一种非常有效的重构手段。通过确定一个方法的高层目标,就可以识别和提取出无关的子问题域,让方法的职责变得更加单一,代码的层次更加清晰。方法在代码层次是一种非常有效的封装机制,它可以让细节不再直接暴露,只要提取出来的方法拥有一个“不言自明”的好名称,代码就能变得更加可读。</p>
<p>运行所有测试,全部通过。</p>
<h4>全面考虑任务的测试用例</h4>
<p>现在还要考虑一种特殊的测试用例,即在结算薪资时,钟点工没有提交任何工作时间卡。在考虑这一测试方法的编写时,<strong>发现一个问题</strong>:如何获得薪资的结算周期?之前的实现是通过提交的工作时间卡来获得结算周期的,如果钟点工根本没有提交工作时间卡,意味着该钟点工的薪资为 0但并不等于说没有薪资结算周期。事实上如果提交的工作时间卡存在缺失也会导致获取薪资结算周期出错。以此而论即可发现确定薪资结算周期的职责本身就不应该由 HourlyEmployee 来承担该聚合也不具备该“知识”然而payroll() 方法返回的 Payroll 对象又需要有结算周期,故而结算周期的信息事实上应该由外部传入,这就<strong>保证了聚合的自给自足</strong>,无需访问任何外部资源。因此,在编写新测试之前,还需要先修改已有代码:</p>
<pre><code class="language-java"> public Payroll payroll(Period period) {
Money regularSalary = calculateRegularSalary();
Money overtimeSalary = calculateOvertimeSalary();
Money totalSalary = regularSalary.add(overtimeSalary);
return new Payroll(
period.beginDate(),
period.endDate(),
totalSalary);
}
</code></pre>
<p>新增测试覆盖了没有工作时间卡的情况。注意以下两个测试应分别实现、分别验证,严格遵守测试驱动开发的红绿黄节奏:</p>
<pre><code class="language-java">@Test
public void should_be_0_given_no_any_timecard() { }
@Test
public void should_be_0_given_null_timecard() { }
</code></pre>
<p>在编写第二个测试时,发现之前的测试并没有考虑 List<TimeCard> 为 null 的情况,导致测试未能通过。修改实现,对 null 与空列表二者都做了判断:</p>
<pre><code class="language-java"> public Payroll payroll(Period period) {
if (Objects.isNull(timeCards) || timeCards.isEmpty()) {
return new Payroll(period.beginDate(), period.endDate(), Money.zero());
}
Money regularSalary = calculateRegularSalary();
Money overtimeSalary = calculateOvertimeSalary();
Money totalSalary = regularSalary.add(overtimeSalary);
return new Payroll(period.beginDate(), period.endDate(), totalSalary);
}
</code></pre>
<p>这充分说明,倘若编写的测试用例不完整,就会影响到产品代码的外部质量,甚至引入未知的缺陷。测试驱动开发确乎可以推动开发人员编写大量的单元测试或集成测试,并利用这些测试形成重构的保护网,但测试驱动开发自身并不能决定测试的质量与覆盖率。故而从分解的子任务到测试用例的编写是从设计迈向实现的关键一步,若能结合用户故事的验收标准来确定测试用例,又或者由测试人员参与,结对编写测试方法,会在一定程度保证测试用例的完整性。</p>
<h4>领域驱动设计对测试的影响</h4>
<p>仔细检查测试的断言,发现对 Payroll 的验证并不完全,因为工资条必须要与员工对应,故而需要验证 Payroll 与员工相关的信息,其中一个关键属性是员工的 ID。因为 HourlyEmployee 是一个聚合根,按照领域驱动设计的要求,需要为实体定义一个唯一的身份标识。倘若 Payroll 也具有员工的ID就能与 HourlyEmployee 关联。因此,为已有测试增加新的验证,由此驱动我们修改 HourlyEmployee 与 Payroll 模型的定义:</p>
<pre><code class="language-java">assertThat(payroll.employeId()).isEqualTo(employeeId);
</code></pre>
<p>现在我们已经完成了一个原子任务,可以删去该任务。这一做法一方面真实体现了项目开发的进展情况,让开发人员及时收获任务完成的成就感,另一方面也能指导我们的开发工作小步前进,有条不紊:</p>
<ul>
<li>计算钟点工薪资
<ul>
<li>获取钟点工雇员与工作时间卡</li>
<li>~~根据雇员日薪计算薪资~~</li>
</ul>
</li>
</ul>
<h4>新任务的测试驱动开发</h4>
<p>选择下一个任务。通常应选择与前一任务相关的任务,保证每个组合任务的原子任务能够有序地完成。由于原子任务“获取钟点工雇员与工作时间卡”会访问数据库,从单元测试的角度来讲,应以 Mock 形式实现,因此可以直接为组合任务“计算钟点工薪资”编写测试,由此驱动出 Repository 的接口。</p>
<p>在进行测试驱动开发时,<strong>需要考虑测试用例的正交性</strong>:无需为相同的业务场景和相同的测试数据重复编写测试,尤其要考虑组合任务与原子任务之间的关系。由于测试驱动开发的方向是由内至外,即先为内部的原子任务编写测试,再为外部的组合任务编写测试。倘若原子任务的测试用例足够完整,在为组合任务编写测试时,就只需要为组合点的集成逻辑编写测试方法即可。</p>
<p>以组合任务“计算钟点工薪资”为例,由于原子任务“根据雇员日薪计算薪资”已经考虑了为单个钟点工计算薪资的各种情况,那么,组合任务“计算钟点工薪资”的测试用例就无需重复覆盖这些用例。这意味着,编写的测试只需考虑获取钟点工雇员数量的各种情形,但单个雇员与工作时间卡之间的各种情形就无需再考虑,否则就会形成测试用例的“组合爆炸”。因此,组合任务的测试用例就分为:</p>
<ul>
<li>没有符合条件的钟点工雇员</li>
<li>只有一个钟点工雇员符合条件</li>
<li>多个钟点工雇员符合条件</li>
</ul>
<p>根据场景驱动设计的要求,由领域服务 HourlyEmployeePayrollCalculator 履行组合任务“计算钟点工薪资”的职责。由于对 HourlyEmployeeRepository 资源库接口采用模拟的手段,由此可以站在调用者的角度驱动出它的接口。现在,按照测试驱动开发三定律的要求为新功能编写测试:</p>
<pre><code class="language-java">public class HourlyEmployeePayrollCalculatorTest {
@Test
public void should_calculate_payroll_when_no_matched_employee_found() {
//given
HourlyEmployeeRepository mockRepo = mock(HourlyEmployeeRepository.class);
when(mockRepo.allEmployeesOf()).thenReturn(new ArrayList&lt;&gt;());
HourlyEmployeePayrollCalculator calculator = new HourlyEmployeePayrollCalculator();
calculator.setRepository(mockRepo);
Period settlementPeriod = new Period(LocalDate.of(2019, 9, 2), LocalDate.of(2019, 9, 6));
//when
List&lt;Payroll&gt; payrolls = calculator.execute(settlementPeriod);
//then
verify(mockRepo, times(1)).allEmployeesOf();
assertThat(payrolls).isNotNull().isEmpty();
}
}
</code></pre>
<p>通过该测试可以驱动出领域服务、资源库和聚合之间的协作关系。测试的 Given 部分需要考虑如何定义 HourlyEmployeeRepository 接口及接口方法。When 部分驱动出领域服务的接口设计包括方法名、输入参数和返回结果。场景驱动设计的时序图脚本可以作为参考但不可拘泥于设计模型。由于在编写测试时能够更多地站在调用者角度去考虑领域设计模型的结果就能在进一步细化的过程中向着正确的方向演化。Then 部分通过 Mockito 的 verify() 方法强调了领域服务与资源库的协作,并对领域服务方法返回结果的正确性进行了验证。</p>
<p>运行测试,失败。然后针对测试编写刚好令其通过的实现:</p>
<pre><code class="language-java">public class HourlyEmployeePayrollCalculator {
private HourlyEmployeeRepository employeeRepository;
public void setRepository(HourlyEmployeeRepository employeeRepository) {
this.employeeRepository = employeeRepository;
}
public List&lt;Payroll&gt; execute(Period settlementPeriod) {
List&lt;HourlyEmployee&gt; hourlyEmployees = employeeRepository.allEmployeesOf();
return hourlyEmployees.stream()
.map(e -&gt; e.payroll(settlementPeriod))
.collect(Collectors.toList());
}
}
</code></pre>
<p>组合任务除了引入了 Mockito 来模拟 HourlyEmployeeRepository 的行为之外驱动开发的步骤与过程与之前实现的原子任务“根据雇员日薪计算薪资”并没有任何差别。为了节省文字这里就不再继续赘述。唯一需要注意的是随着测试的增加必然需要为测试数据的准备提供工具方法。这在单元测试中往往被称之为测试夹具Fixture。例如为钟点工雇员提供创建方法</p>
<pre><code class="language-java">public class EmployeeFixture {
public static HourlyEmployee hourlyEmployeeOf(String employeeId, List&lt;TimeCard&gt; timeCards) {
Money salaryOfHour = Money.of(100.00, Currency.RMB);
return new HourlyEmployee(employeeId, timeCards, salaryOfHour);
}
public static HourlyEmployee hourlyEmployeeOf(String employeeId, int workHours1, int workHours2, int workHours3, int workHours4, int workHours5) {
Money salaryOfHour = Money.of(100.00, Currency.RMB);
List&lt;TimeCard&gt; timeCards = createTimeCards(workHours1, workHours2, workHours3, workHours4, workHours5);
return new HourlyEmployee(employeeId, timeCards, salaryOfHour);
}
}
</code></pre>
<p>若要了解薪资管理系统的整个驱动开发过程可以在GitHub上获取我按照场景驱动设计与测试驱动开发结合的方式为该系统编写的代码代码库为 <a href="https://github.com/agiledon/payroll-ddd">https://github.com/agiledon/payroll-ddd</a></p>
<h3>场景驱动设计与测试驱动开发的相互作用</h3>
<p>观察测试驱动开发的过程,若已有领域设计模型包括场景驱动设计作为指导,会让测试驱动开发的过程变得更加简单;反过来,测试驱动开发的过程又能通过编写测试代码和产品代码来验证领域模型的设计是否合理。例如组合任务“支付”的时序图脚本设计为:</p>
<pre><code>PayingPayrollService.execute(employees) {
TransferClient.transfer(account);
PaymentRepository.add(payment);
}
</code></pre>
<p>通过测试驱动开发完成了“计算雇员薪资”组合任务后,我们确定了领域服务方法返回的类型为 List<Payroll>。支付时,应对雇员的银行账户发起转账。在薪资管理系统中,账户 Account 属于另一个聚合,它与雇员之间存在关联关系。由于 Payroll 包含了 employeeId故而领域服务 PayingPayrollService 还需要通过 AccountRepository 获得对应的账户信息,然后再对账户发起转账。</p>
<p>显然,在场景驱动设计的过程中,通过时序图的消息传递可以帮助我们思考接口设计,并编写出用例图脚本。然而,这个设计过程毕竟没有得到代码的验证,难免会出现设计误差或缺失。更何况,在没有编码实现之前,我们也不适宜在设计上花费太多的时间,以免导致过度设计。测试驱动开发能以快速反馈的实证主义,帮我们验证接口的合理性,然后在测试的保护下利用重构来改进代码质量。这正是我之所以建议将测试驱动开发与场景驱动设计相结合的主要原因。</p>
<p>随着测试驱动开发的逐步进行,驱动出越来越多的领域模型对象。我们需要按照领域驱动设计的要求与设计原则调整代码结构,例如按照限界上下文与聚合的粒度将不同的领域模型对象放到不同的模块与包中,对应的测试代码也应做响应的调整。如下图所示:</p>
<p><img src="assets/3c6150b0-048a-11ea-9e3a-93105945f4da" alt="eaaf2f63-c29a-47c2-9609-2632be6adc1f.png" /></p>
<p>整个代码结构按照限界上下文进行划分,在限界上下文内部则按照分层架构划分。由于目前仅仅驱动出领域模型对象,上图的 payrollcontext 限界上下文中仅有 domain 层。在限界上下文中的领域层,则按照聚合的边界进行划分,跨聚合重用的领域模型对象直接放在限界上下文领域层的命名空间下。调整了代码结构后,一定要运行所有测试,避免在调整类的命名空间时,引入未知的编译错误。确定代码结构的工作越早进行越好,当要大范围调整的类数量越来越多时,调整的成本也会越来越高,影响也会越来越大。</p>
</div>
</div>
<div>
<div style="float: left">
<a href="/专栏/领域驱动设计实践(完)/085 案例 薪资管理系统的测试驱动开发(上).md.html">上一页</a>
</div>
<div style="float: right">
<a href="/专栏/领域驱动设计实践(完)/087 对象关系映射(上).md.html">下一页</a>
</div>
</div>
</div>
</div>
</div>
</div>
<a class="off-canvas-overlay" onclick="hide_canvas()"></a>
</div>
<script defer src="https://static.cloudflareinsights.com/beacon.min.js/v652eace1692a40cfa3763df669d7439c1639079717194" integrity="sha512-Gi7xpJR8tSkrpF7aordPZQlW2DLtzUlZcumS8dMQjwDHEnw9I7ZLyiOj/6tZStRBGtGgN6ceN6cMH8z7etPGlw==" data-cf-beacon='{"rayId":"70997ec559af3cfa","version":"2021.12.0","r":1,"token":"1f5d475227ce4f0089a7cff1ab17c0f5","si":100}' crossorigin="anonymous"></script>
</body>
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-NPSEEVD756"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag('js', new Date());
gtag('config', 'G-NPSEEVD756');
var path = window.location.pathname
var cookie = getCookie("lastPath");
console.log(path)
if (path.replace("/", "") === "") {
if (cookie.replace("/", "") !== "") {
console.log(cookie)
document.getElementById("tip").innerHTML = "<a href='" + cookie + "'>跳转到上次进度</a>"
}
} else {
setCookie("lastPath", path)
}
function setCookie(cname, cvalue) {
var d = new Date();
d.setTime(d.getTime() + (180 * 24 * 60 * 60 * 1000));
var expires = "expires=" + d.toGMTString();
document.cookie = cname + "=" + cvalue + "; " + expires + ";path = /";
}
function getCookie(cname) {
var name = cname + "=";
var ca = document.cookie.split(';');
for (var i = 0; i < ca.length; i++) {
var c = ca[i].trim();
if (c.indexOf(name) === 0) return c.substring(name.length, c.length);
}
return "";
}
</script>
</html>