像这样做单元测试

原创
2013/09/18 01:07
阅读数 6K

本文是《轻量级 Java Web 框架架构设计》的系列博文。

单元测试,对于每个程序员来说,都是必不可少的,但实际情况却不尽人意。有些程序员认为单元测试是在浪费自己的时间,有写单元测试的时间,还不如把系统跑起来,到处点点,看看哪里在报错,这样还来得快些。

也许这些程序员还没有真正认识到单元测试的意义所在,或许是不太清楚单元测试应该如何去做吧。

如果您对单元测试还存在以上偏见,就请继续阅读吧,或许我会让您对单元测试又有了重新的认识。

开始!

一般我们都会选择一款单元测试框架,Java 业界最成熟的应该是 JUnit。我们可以通过 JUnit 编写测试类(在 JUnit 中叫做 Test Suit,翻译为“测试套件”),在测试类中我们可以定义一个或多个被测方法(在 JUnit 中叫做 Test Case,翻译为“测试用例”)。

以上这些基本概念想必大家都知道,但是却往往忽略了“数据初始化脚本”这个东西,它可以是一份 SQL 文件,内容大致是这样的:

TRUNCATE TABLE `product`;
INSERT INTO `product` (`id`, `product_type_id`, `product_name`, `product_code`, `price`, `description`) VALUES ('1', '1', 'iPhone 3gs', 'MP001', '3500', 'iPhone 3gs 移动电话');
INSERT INTO `product` (`id`, `product_type_id`, `product_name`, `product_code`, `price`, `description`) VALUES ('2', '2', 'iPad 2', 'TC001', '3000', 'iPad 2 平板电脑');
INSERT INTO `product` (`id`, `product_type_id`, `product_name`, `product_code`, `price`, `description`) VALUES ('3', '1', 'iPhone 4', 'MP002', '4000', 'iPhone 4 移动电话');
INSERT INTO `product` (`id`, `product_type_id`, `product_name`, `product_code`, `price`, `description`) VALUES ('4', '1', 'iPhone 4s', 'MP003', '4500', 'iPhone 4s 移动电话');
INSERT INTO `product` (`id`, `product_type_id`, `product_name`, `product_code`, `price`, `description`) VALUES ('5', '2', 'iPad 3', 'TC002', '3000', 'iPad 3 平板电脑');
INSERT INTO `product` (`id`, `product_type_id`, `product_name`, `product_code`, `price`, `description`) VALUES ('6', '1', 'iPhone 5', 'MP004', '5000', 'iPhone 5 移动电话');
INSERT INTO `product` (`id`, `product_type_id`, `product_name`, `product_code`, `price`, `description`) VALUES ('7', '2', 'iPad mini', 'TC003', '3000', 'iPad mini 平板电脑');

以上是一份基于 MySQL 数据库的 SQL 脚本,第一条 TRUNCATE 语句表示把表中所有的数据都清空,下面一批 INSERT 语句表示向该表中猛烈地插入数据。

我们希望在做单元测试之前,这份 SQL 脚本需要自动执行,并灌入数据库中。这样一来,每次做单元测试时,都能保证数据是干干净净的。最好能在做完之后,再次自动执行一遍这个脚本,始终让数据保持前后一致。也就是说,做了和没做都不会对数据有影响。

换言而之,单元测试必须要有“复用性”,绝不要因为测了一次,就把数据库给毁了,下次测试前还要手工修改回来。此外,竟然有些同学还直接连接开发数据库进行单元测试,测完之后,把开发所需要的数据也给毁了。甚至,还有些更可爱的同学直接连接生产数据库做单元测试。您别不相信,有些小伙伴们确实是在做这样的傻事,怪不得总是说单元测试不好,没必要做了。

然而,借助 JUnit 提供的特性,我们可以轻松地执行数据初始化脚本,并且批量执行测试用例。下面给一个例子,来说明这一切。

public class ProductServiceTest extends BaseTest {

    private ProductService productService = BeanHelper.getBean(ProductServiceImpl.class);

    @BeforeClass
    @AfterClass
    public static void init() {
        initSQL("sql/product.sql");
    }

    @Test
    @Order(1)
    public void getProductListTest() {
        List<Product> productList = productService.getProductList();
        Assert.assertNotNull(productList);
        Assert.assertEquals(productList.size(), 7);
    }

    @Test
    @Order(2)
    public void getProductTest() {
        long productId = 1;
        Product product = productService.getProduct(productId);
        Assert.assertNotNull(product);
    }

    @Test
    @Order(3)
    public void createProductTest() {
        Map<String, Object> productFieldMap = new HashMap<String, Object>();
        productFieldMap.put("productTypeId", 1);
        productFieldMap.put("productName", "1");
        productFieldMap.put("productCode", "1");
        productFieldMap.put("price", 1);
        productFieldMap.put("description", "1");
        boolean result = productService.createProduct(productFieldMap);
        Assert.assertTrue(result);
    }

    @Test
    @Order(4)
    public void updateProductTest() {
        long productId = 1;
        Map<String, Object> productFieldMap = new HashMap<String, Object>();
        productFieldMap.put("productName", "1");
        productFieldMap.put("productCode", "1");
        boolean result = productService.updateProduct(productId, productFieldMap);
        Assert.assertTrue(result);
    }

    @Test
    @Order(5)
    public void deleteProductTest() {
        long productId = 1;
        boolean result = productService.deleteProduct(productId);
        Assert.assertTrue(result);
    }
}

以上创建了一个 ProductServiceTest 类,根据名字就知道,它是用来测试 ProductService 接口的,所以在代码中通过 BeanHelper.getBean() 方法,获取了该接口的一个实现类实例。

需要注意的是,这个测试类必须继承 BaseTest 类,在该父类中,提供了一些单元测试需要的基础功能,下面会逐步提到。

需要定义一个 static 的 init() 方法,并且在该方法上标注 @BeforeClass 与 @AfterClass 注解,表示该方法会在所有被测方法执行前与执行后自动调用,可理解为在 Test Class 加载前后来又 JUnit 框架来调用。在该方法中需要 调用 initSQL() 方法(它是 BaseTest 为我们提供的),用于执行数据初始化脚本,使测试前后数据保持一致。

下面凡是带有 @Test 注解的方法也就是被测方法了。与传统的 JUnit 不同的是,这里使用了自定义注解 @Order,可在参数中输入序号,以保证所有的测试方法会根据指定的顺序依次执行,这是 JUnit 不具备的,所以需要扩展!其实,JUnit 内部是通过一个 HashMap 来存放所有的被测方法的,于是在调用的时候,获取这些被测方法的顺序就无法保证线性了。这确实是一个坑,很多人,甚至有些 Java 高手们,都认为测试顺序与方法定义的顺序是一致的,其实是不一定的。

先回答一个问题:为什么要使被测方法具备顺序性?

如果使用 Maven 以及持续集成工具的时候,会自动调用所有的测试类中所有的被测方法,如果不保证顺序,就会出现类似这样的错误:先测试 deleteProductTest() 方法,再调用 getProductTest() 方法,那么整个单元测试就失败了。所以,很有必要保证被测方法的顺序性。

那么如何使 @Order 注解生效了?是时候看看 BaseTest 类了:

@RunWith(OrderedRunner.class)
public abstract class BaseTest {

    private static final Logger logger = Logger.getLogger(BaseTest.class);

    protected BaseTest() {
        InitHelper.init();
    }

    protected static void initSQL(String sqlPath) {
        try {
            File sqlFile = new File(ClassUtil.getClassPath() + sqlPath);
            List<String> sqlList = FileUtils.readLines(sqlFile);
            for (String sql : sqlList) {
                DBHelper.update(sql);
            }
        } catch (Exception e) {
            logger.error(e.getMessage(), e);
            throw new RuntimeException(e.getMessage(), e);
        }
    }
}

首先,需要注意的是,类的头上放了一个 @RunWith 注解,这是 JUnit 给我们提供的, 默认是  org.junit.runners. JUnit4,我们不妨称它为 Runner,可将它理解为一个测试环境的运行器即可。我们下面需要用自定义的 OrderedRunner 来取代 JUnit 默认的 Runder,就是在自定义 Runner 中确保被测方法的顺序性的。

然后,在构造方法中调用 InitHelper.init() 方法来初始化所有的 Helper 类,这相当于框架的初始化过程。

最后,提供了一个 initSQL() 方法,读取 classpath 下的 SQL 文件,然后调用 DBHelper.update() 方法执行这些 SQL 语句。

下面就是重点了,如何来实现 OrderedRunner?直接上源码吧:

public class OrderedRunner extends BlockJUnit4ClassRunner {

    // 定义一个静态变量,确保 computeTestMethods() 中的排序逻辑只运行一次(JUnit 会调用两次)
    private static List<FrameworkMethod> testMethodList;

    public OrderedRunner(Class<?> cls) throws InitializationError {
        super(cls);
    }

    @Override
    protected List<FrameworkMethod> computeTestMethods() {
        if (testMethodList == null) {
            // 获取带有 @Test 注解的方法
            testMethodList = super.computeTestMethods();
            // 获取测试方法上的 @Order 注解,并对所有的测试方法重新排序
            Collections.sort(testMethodList, new Comparator<FrameworkMethod>() {
                @Override
                public int compare(FrameworkMethod m1, FrameworkMethod m2) {
                    Order o1 = m1.getAnnotation(Order.class);
                    Order o2 = m2.getAnnotation(Order.class);
                    if (o1 == null || o2 == null) {
                        return 0;
                    }
                    return o1.value() - o2.value();
                }
            });
        }
        return testMethodList;
    }
}

以上代码中,做了两件很有意义的事情:

第一,使用一个 static 的 testMethodList,以确保 JUnit 调用 computeTestMethods() 方法后返回的是同一个实例(这里用到了 Singleton 设计模式)。如果不做这样的改动,JUnit 就会调用 computeTestMethods() 方法两次,重复执行其中的那一堆代码,我个人认为这是没有必要的。可以看看 org.junit.runners.BlockJUnit4ClassRunner 的源码(400 多行),相信您就会知道我为什么要这样简化了。

第二,在 computeTestMethods() 方法中,调用父类的 computeTestMethods() 方法,此时拿回来的 testMethodList 的顺序是基于 Hash 算法的,也就是无序的。需要对这个 testMethodList 进行排序,通过获取被测方法上 @Order 注解的值进行升序排列,最终返回排序后的 testMethodList。

总结一下我们所做的事情:

  1. 提供数据初始化脚本(SQL 脚本)。
  2. 使用 @BeforeClass/@AfterClass 注解的 init 方法执行以上 SQL 脚本。
  3. 使用 @Order 注解设置被测方法的执行顺序。
  4. 提供 OrderedRunner 取代 JUnit 默认的 Runner。

这样的单元测试才是我们想要的,打完收功!期待您中肯的评论!

展开阅读全文
加载中
点击加入讨论🔥(6) 发布并加入讨论🔥
打赏
6 评论
21 收藏
7
分享
返回顶部
顶部