作为一名实习生,我尝试使用TDD开发一个相对时间计算函数

01 背景

实习的第二周,我接到了一个任务。要实现一个类似微信聊天记录中的相对时间显示函数,呈现规则如下:

当天的消息时间显示为 时:分

上一个自然天内的消息时间显示为 昨天 时:分

2天至7天内的消息时间显示为 星期x 时:分

大于7天的消息时间显示为 x年x月x日 时:分

根据导师的建议,我采用了测试驱动开发(TDD)的模式完成这个函数实现,并在测试代码编写过程中使用了等价类划分法与边界值分析法设计测试用例。本文介绍了该函数的具体实现过程及其涉及的知识点,包括TDD、测试用例设计方法,及重构手段等。

02 什么是TDD

首先我们需要了解什么是TDD,以及如何通过这种模式完成开发。

TDD,即测试驱动开发(Test-Driven Development),是一种开发理念。它的核心思想是,在开发人员实现功能代码前,先设计好测试用例,然后再根据测试用例编写产品的功能代码,最终目的是让开发前设计的测试用例都能够顺利执行通过。TDD的基本思路就是通过测试来推动整个开发过程。

对于开发人员来说,TDD模式意味着他需要参与到这个功能的完整设计过程中,而不是凭自己想象去开发一个功能。他有一个非常明确的目标,就是要让提前设计的测试用例都可以顺利通过。

红-绿-重构

TDD模式开发需要遵循一个非常经典的步骤:“红,绿,重构”。

在使用TDD模式进行开发时,一个完整的开发周期包含以下三步,然后我们要做的,就是不断重复这个周期。

  • 红。这一步需要先准备好测试用例,而功能代码未实现。所谓的红,就是指测试用例未通过的状态。
  • 绿。这一步需要实现功能代码,让测试用例依次通过。
  • 重构。当测试通过之后,我们就可以在不影响功能正确的基础上,进行代码重构。

03 等价类与边界值

了解TDD后,根据TDD开发步骤,我们应首先开始编写测试用例。此时又遇到一个问题,如何设计测试用例?根据背景需求,我采用了两种最基本且最常用的测试用例设计方法:等价类划分法与边界值分析法。

等价类划分法

简介

等价类就是某个输入域的子集。等价类划分法是把所有可能的输入数据集合划分成若干个子集,每个子集内的元素对于揭露程序中的错误都是等效的,在每个等价类中取一两个数据作为测试的输入数据即可,这样就可以用少量代表性的测试数据取得较好的测试效果。

等价类又划分为“有效等价类”和“无效等价类”。

有效等价类,就是符合需求规格说明书要求的合理、有意义的输入数据集合。利用有效等价类可检验程序是否完整实现了需求所规定的功能以及功能的实现是否正确符合预期。

无效等价类,与有效等价类恰好相反,是指那些不合理的、无意义的输入数据所构成的集合。这类测试数据可反向验证功能的正确性和程序的容错处理。

设计用例步骤

第一步,依据需求规格说明书,确定输入数据的范围。

第二步,根据上述范围将所有可能的输入数据划分为若干个有效等价类及无效等价类。

第三步,分别从每个等价类中提取一两个有代表性的数据作为测试数据。一般的,每提取出一个数据就可设计一条测试用例,或根据实际业务需求用最少数量的用例覆盖最多的测试场景。

边界值分析法

简介

边界值分析是通过选取指定数据域的“上点”、“内点”、“离点”来测试输入或输出的边界。

上点:就是边界上的点,无论域是开区间还是闭区间。若是开区间,上点在域外;若是闭区间,上点就在域内。

离点:是指离“上点”最近的点,这里跟待测数据域是闭区间还是开区间有关系。如果是开区间,那么离点就在域内;如果是闭区间,那么离点就在域外。

内点:域内的任意点都是内点。

设计用例步骤

第一步,确定测试域。

第二步,找到“上点”、“内点”、“离点”。

第三步,每个“上点”和“离点”就是一条用例,“内点”可选取代表性的中点创建一条用例。

04 单元测试用例编写

了解了所有跟TDD和等价类、边界值相关的知识后,我们正式进入TDD的第一步:编写测试用例。

第一步:确定输入数据的范围

相对时间显示函数(下称relativeTime函数)支持Date和number类型入参,其中number类型为整数,代表自 UTC 1970 年 1 月 1 日 00:00:00 (ECMAScript 纪元,与 UNIX 纪元相同)以来的毫秒数,忽略闰秒。

第二步:划分有效等价类和无效等价类

由背景需求可知,应划分为当天时间戳,上一个自然天内时间戳,2天至7天内时间戳,大于7天的时间戳共四个有效等价类。无效等价类包括:空字符、undefined、null、NaN、非整数数字、超过当前时间的时间戳。

第三步:结合边界值分析法确定测试数据

每一个等价类,结合边界值分析,可以得到对应的上点、内点、离点如下:

等价类 上点 内点 离点
(有效)当天时间戳 0:00、当前时间戳 01:01 昨天23:59
(有效)上一个自然天内时间戳 昨天0:00、昨天23:59 昨天01:01 2天前23:59、今天0:00
(有效)2天至7天内时间戳 7天前0:00、2天前23:59 3天前01:01 8天前23:59、昨天0:00
(有效)大于7天的时间戳 8天前23:59 10天前01:01 7天前0:00
无效等价类 空字符、undefined、null、NaN、非整数数字、超过当前时间的时间戳

第四步:编写测试用例

确定测试用例后,可以写出测试代码。部分测试代码示例如下:

describe('test relativeTime', () => {
    // 无效等价类,返回空字符
    it('传入空字符/undefined/null/NaN时应返回空字符', () => {
        expect(relativeTime('')).toBe('');
        expect(relativeTime(undefined)).toBe('');
        expect(relativeTime(null)).toBe('');
        expect(relativeTime(NaN)).toBe('');
    });
    it('传入超过当前时间的时间戳时应返回空字符', () => {
        expect(relativeTime(new Date().setMinutes(now.getMinutes() + 1))).toBe('');
    });
    it('传入非整数数字', () => {
        expect(relativeTime(1.5)).toBe('');
    })
    // 有效等价类
    it('传入当前时间戳应返回xx:xx', () => {
        expect(relativeTime(now)).toBe(`${now.getHours() < 10 ? '0' + now.getHours() : now.getHours()}:${now.getMinutes() < 10 ? '0' + now.getMinutes() : now.getMinutes()}`);
    });
    it('传入当天的消息时间戳应返回xx:xx', () => {
        expect(relativeTime(new Date().setHours(1, 1))).toBe('01:01');
    });
    it('传入当天零点的时间戳应返回xx:xx', () => {
        expect(relativeTime(new Date().setHours(0, 0))).toBe('00:00');
    });
    it('传入上一个自然天23:59的消息时间戳应返回 昨天 xx:xx', () => {
        let yesterdayEnd = new Date();
        yesterdayEnd.setDate(now.getDate() - 1);
        yesterdayEnd.setHours(23, 59);
        expect(relativeTime(yesterdayEnd)).toBe('昨天 23:59');
    });
    // ...
})

除此之外,由于relativeTime函数中的一个重要子功能是计算两个时间戳的日期相差天数,需要编写函数diffDays实现该功能。使用同样的方法编写diffDays的测试代码,包括2月(28/29天),大小月等边界情况。部分测试代码示例如下:

describe('test getDiffDays', () => {
    it('相差一天的数据应该计算正确', () => {
        expect(getDiffDays(new Date('2023-7-23 12:00'), new Date('2023-7-24 12:00'))).toBe(1);
    });
    it('相隔1个自然天但实际时间差小于1天的数据应该返回1', () => {
        expect(getDiffDays(new Date('2023-7-23 18:00'), new Date('2023-7-24 12:00'))).toBe(1);
    });
    it('1月份到2月份过度的日期差值应该正确', () => {
        expect(getDiffDays(new Date('2023-1-30 12:00'), new Date('2023-2-1 12:00'))).toBe(2);
    });
    it('2月份到3月份过度的日期差值应该正确', () => {
        expect(getDiffDays(new Date('2023-2-27 12:00'), new Date('2023-3-1 12:00'))).toBe(2);
    });
    // ...
})

此时功能代码未实现,单元测试不能通过,处于TDD开发步骤中一个周期的“红”阶段。

05 代码编写

完成单元测试后,接下来进行函数功能的代码实现。

实现思路

代码实现思路如下:

  1. 首先处理非整数数字,空字符等属于无效等价类的输入数据

  2. 接下来计算出时间戳距离当前时间相隔的天数,并根据差值天数进行下述几个路径的处理

    • 若差值天数为0,则直接显示【时:分】
    • 若差值天数为1,则显示【昨天 时:分】
    • 若差值天数>=2且<=7,则显示【星期x 时:分】
    • 若差值天数>7,则显示【x年x月x日 时:分】

date-fns简介

代码实现中我使用了date-fns日期工具。date-fns是一个非常好用的JS时间处理库,同时支持浏览器和NodeJS环境,而且使用方便,API全面,有200多种功能,适用于几乎所有场合。基于功能需求,我使用了date-fns的startOfDay(), differenceInDays(), getHours(), getMinutes(), getDay(), getYear(), getMonth(), format()函数。

初版代码实现

在编写代码的过程中,我通过npm run test命令在针对每一个测试用例编写完成后进行功能测试,直至所有测试用例都全部通过。在这个过程中,我经历了若干次“红-绿”的阶段。最终得到的代码如下:

import * as datefns from 'date-fns'; 
function relativeTime(value: Date | number): string | number | Date {
    // 传入空字符, undefined, null
    if (!value) {
        return '';
    }
    // 传入NaN或非整数数字
    if (typeof value === 'number') {
        if ((!Number.isInteger(value)) || isNaN(value))
            return '';
    }
    const now = new Date();
    const startOfDayForTarget = datefns.startOfDay(value);
    const startOfDayForToday = datefns.startOfDay(now);
    const diffDays = datefns.differenceInDays(startOfDayForToday, startOfDayForTarget);
    enum weekDay { 日, 一, 二, 三, 四, 五, 六 };
    if(diffDays===0){
        if(datefns.getHours(value)>datefns.getHours(now)){
            return '';
        }else if(datefns.getHours(value)===datefns.getHours(now)&&datefns.getMinutes(value)>datefns.getMinutes(now)){
            return '';
        }else{
            return datefns.format(value, "HH:mm");
        }
    }else if (diffDays == 1) {
        return `昨天 ${datefns.format(value, "HH:mm")}`;
    } else if (diffDays >= 2 && diffDays <= 7) {
        return `星期${weekDay[datefns.getDay(value)]} ${datefns.format(value, "HH:mm")}`;
    } else if (diffDays > 7) {
        return `${datefns.getYear(value)}年${datefns.getMonth(value) + 1}月${datefns.getDate(value)}日 ${datefns.format(value, "HH:mm")}`;
    } else {
        return '';
    }
};
export { relativeTime }; 

06 重构

至此,我们已经实现了目标功能。

重构点分析

但是仔细阅读上述代码,还有很大的改进空间,我排查并列出以下几点:

1、由于作为一个二方包不应该引入太多的外部依赖,需要消除date-fns进行代码重构,使用原生Date类型方法实现功能。

2、代码行数过大,需要通过提炼函数进行瘦身。

3、无效等价类识别功能点分布在代码的不同区域。

4、操作符前后是否添加空格等代码风格问题。

重构代码实现

重构完成后,代码如下:

const getDayStart = (date: Date) => {
    // 必须要使用一个新的值来存储,否则会影响原来的值,进而影响最终计算结果
    const res = new Date(date);
    return res.setHours(0, 0, 0, 0);
}
const isValidDate = (date: Date | number) => {
    if (!date) {
        return false;
    } else if (typeof date === 'number' && !Number.isInteger(date)) {
        return false;
    } else if (date > new Date()) {
        return false;
    } else {
        return true;
    }
}
export const getDiffDays = (start: Date, end: Date) => {
    // 计算时间戳差值
    const tsDiff = getDayStart(end) - getDayStart(start);
    // 将时间戳差值转换为天数
    const dayDiff = Math.floor(tsDiff / (1000 * 60 * 60 * 24));
    return dayDiff;
}
export enum WeekDay { 日, 一, 二, 三, 四, 五, 六 };

const getDisplayTimeSuffix = (date: Date) => {
    return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
}
const getDisplayTimePrefix = (pastDays: number, date: Date) => {
    if (pastDays === 0) {
        return '';
    } else if (pastDays === 1) {
        return '昨天 ';
    } else if (pastDays >= 2 && pastDays <= 7) {
        return `星期${WeekDay[date.getDay()]} `;
    } else {
        return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日 `
    }
}

const relativeTime = (value: Date | number) => {
    if (!isValidDate(value)) {
        return '';
    }
    const now = new Date();
    const dateValue = new Date(value);
    const diffDays = getDiffDays(dateValue, now);
    const dateValueSuffix = getDisplayTimeSuffix(dateValue);
    const dateValuePrefix = getDisplayTimePrefix(diffDays, dateValue);
    return dateValuePrefix + dateValueSuffix;
};
export { relativeTime };

打包体积减少25%

对比代码重构前后的打包结果:

重构前:

image.png

重构后:

image.png

可以看出重构不仅使代码可读性有了较大的提升,同时也使打包结果的体积减小了约1/4,实现了较好的优化,此时即完成了TDD开发步骤中一个周期最后的“重构”阶段。

07 总结

本文介绍了测试驱动开发模式,包括其核心思想与具体的开发步骤等,并通过相对时间计算函数的实现,以实例讲解的方式详细展示了TDD的具体过程。涉及TDD,红-绿-重构,等价类与边界值,代码优化等知识点。希望对大家有所启发。

08 参考文章

测试驱动开发:红、绿、重构,www.iteye.com/topic/11164…

测试先行:测试驱动开发(TDD),time.geekbang.org/column/arti…

测试用例设计方法(一)等价类、边界值,blog.csdn.net/yiwaChen/ar…

文 DevUI 实习生 倚菁

DevUI 开源招募

加入DevUI开源生态建设你将收获什么?

直接的价值:

  1. 通过打造一个实际的vue3组件库项目,学习最新的Vite+Vue3+TypeScript+JSX技术
  2. 学习从0到1搭建一个自己的组件库的整套流程和方法论,包括组件库工程化、组件的设计和开发等
  3. 为自己的简历和职业生涯添彩,参与过优秀的开源项目,这本身就是受面试官青睐的亮点
  4. 结识一群优秀的、热爱学习、热爱开源的小伙伴,大家一起打造一个伟大的产品

长远的价值:

  1. 打造个人品牌,提升个人影响力
  2. 培养良好的编码习惯
  3. 获得华为云DevUI团队的荣誉&认可和定制小礼物
  4. 成为PMC&Committer之后还能参与DevUI整个开源生态的决策和长远规划,培养自己的管理和规划能力
  5. 未来有更多机会和可能

DevUI开源,未来可期!

添加DevUI小助手微信:devui-official,拉你到我们的官方交流群。

这是我们的开源故事:

DevUI开源的故事

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYImBiz7' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片