软件测试第二周作业 WordCounter

Github 项目地址

WordCounter in github

或者百度云下载单个项目 https://pan.baidu.com/s/1Vn0bPcQQExFoLnWcN1yv0Q 密码:ncaq

PSP(Personal Software Process)

PSP2.1 PSP阶段 预估耗时实际耗时(分钟) 实际耗时(分钟)
Planning 计划 10 17
Estimate 估计这个任务需要多少时间 5 10
Development 开发 545 650
- Analysis - 需求分析(包括学习新技术) 120 160
- Design Spec - 生成设计文档 60 90
- Coding Standard - 代码规范 (为目前的开发制定合适的规范) 5 10
- Design - 具体设计 30 30
- Coding - 具体编码 180 210
- Code Review - 代码复审 30 30
- Test - 测试(自我测试,修改代码,提交修改) 120 120
Reporting 报告 105 110
- Test Report - 测试报告 60 90
- Size Measurement - 计算工作量 15 20
- Postmortem & Process Improvement Plan - 事后总结, 并提出过程改进计划 30 232
合计 665 787

解题思路

由于最近事情比较多,而且之前做过的词法分析器与之有很多类似的地方,可以复用解法。目测需求可能有一定的变更,预估完成时间应该不长,较晚才开始着手 Orz。

题目解读:精确定义问题和处理方法

实际上,要做一个代码数目统计的程序也并不简单,因为要了解不同的语言的一些语法特性。要做一个能统计所有种类的代码的数量的程序,会非常麻烦。譬如下图所示,许多现代语言的字符集的支持非常丰富,而采取的编码和计数方式缤纷多彩。

幸运的是,仔细阅读了老师的题目说明后——了解到老师的要求是:

一个用来检测只包含 ASCII 编码的 .c,.h 等文件的统计工具。

没有中文字符是在讨论区的答疑中看到的,故我个人目前详细定义为只包含 ASCII 编码

其中 word 的统计方法为了简化工作,采用了比较费解的统计方法——这也导致了方便检测 .sh 类的文件(其语法中很多地方不允许空格分隔),故详细定义为 .c ,.h 等文件。还有一个问题是在代码中的字符串中如果出现 \n,而代码本身没有换行,只算作字符,那么是算作转义字符,还是算两个正常字符呢?如果需要检测其他语言,像 .py 中单引号字符串 '',双引号字符串"" 和多行字符串 """""" 是否需要支持,多行字符串是算作代码呢?还是算作注释呢?其行数又该如何定义和计算呢?

由于其他语言还涉及到一系列复杂的问题,所以我个人详细定义为只处理 .c 和 .h 文件

  1. 转义字符的问题已提交讨论区,老师说不考虑转义字符。
  2. CRLF 字符的问题已提交讨论区,已找到解决方法。

跨平台也带来一些麻烦,对于换行,Windows 使用的是 CRLF \r\n,而 macOS 等主流类 Unix 系统使用的是 \n。在计算字符时应该如何处理。这些也没有在题目中详细定义。暂时只把这些问题交给 Java 本身。

最终的测试脚本,如果没有参数,或参数不合法,需要报怎样的错误提示,还是仅仅什么都不干,没有明确定义。如果我们在程序中写了提示,是否会导致老师测试失败,从而零分。

思路

基础功能

  1. 读取字符数
  2. 读取单词数
  3. 读取行数
  4. 输出文件

计数比较简单。如果不需要考虑处理字符串的问题,可以直接使用例如 aString.length() 等方法获取信息。如果需要考虑,就要便利字符串,分析计数。

扩展功能

  1. -s 需要使用递归实现文件夹的遍历
  2. -a 需要分析语法信息,用 DFA 实现
  3. -e 需要创建一个屏蔽词的数据结构,在统计 word 时跳过这些屏蔽词

修改:编码构思

主要应用 OOP 的编程思想。
基础功能和扩展功能是否需要读取某个信息是 WordCounter 这个类的一个对象的具体属性。

wc.exe -c file.c //返回文件 file.c 的字符数
wc.exe -w file.c //返回文件 file.c 的单词总数
wc.exe -l file.c //返回文件 file.c 的总行数
wc.exe -o outputFile.txt //将结果输出到指定文件outputFile.txt

由上述要求可知是否需要统计某个信息,是由每一次的命令行参数确定的。那么我们就需要一些实例域来表示使用程序时,是否有那些命令参数。如下:

/** 表示是否有命令参数 {@code -c},即是否需要计算字符数,默认为否 */
private boolean requestChar = false;

/** 表示是否有命令参数 {@code -w},即表示是否需要计算单词数,默认为否 */
private boolean requestWord = false;

/** 表示是否有命令参数 {@code -l},即是否需要计算行数,默认为否 */
private boolean requestLine = false;

/**
 * 表示是否有命令参数 {@code -o},即是否需要打印到指定的输出文件,默认为否
 */
private boolean requestOut = false;

/**
 * 指定的输出文件名
 * {@code -o} 命令参数和 outputFileName 必须成对出现
 */
private String outputFileName = null;

/**
 * 表示是否有命令参数 {@code -s},默认为否
 * 是否需要递归处理目录下符合条件的文件
 */
private boolean requestRecur = false;
/**
 * 需要递归处理的文件夹名
 */
private String folderPath = null;

/**
 * 表示是否有有命令参数 {@code -a},默认为否
 * 是否需要返回更复杂的数据(代码行 / 空行 / 注释行)
 */
private boolean requestMore = false;

/**
 * 表示是否有扩展功能 {@code -e},默认为否
 * {@code -e stopList.txt} 是否需要提供文件,不计入单词统计
 * {@code -e} 命令参数和 stopList.txt 必须成对出现
 */
private boolean requestIgnore = false;

/**
 * 指定的停用词表名
 */
private String stopListName = null;

/** 停用词表 */
private HashSet<String> stopList = null;

根据需求,相关信息的常量——如用来匹配 .c 或 .h 这样的文件名的正则表达式是不会变的常量,故应设置为静态常量,如下:

/** 域显示初始化,匹配 {@code .c} 和 {@code .h} 文件的正则表达式字符串 */
private static final String COUNTABLE_FILE_REG_EX = "(\\w)+.[ch]";

/** 匹配 .txt 文件的正则表达式字符串 (输出文件,停用词表)*/
private static final String TXT_REG_EX = "(\\w)+.txt";

/** 匹配只含有 {@code _A-Za-z0-9} 文件夹名的正则表达式字符串 */
private static final String FOLDER_REG_EX = "(\\w)+";

而怎样读取数据,就设置为方法。如下:

代码说明

由于许多功能没有明确定义,目前看来,基础功能比较简单,就放在了一个类中 WordCounter。

重要变量详细说明

/**
 * 需要计算的所有文件名,支持多个文件,默认为空,而非 {@code null}。
 */
private ArrayList<String> countFileNames = new ArrayList<>();

/**
 * 需要计算的所有文件,默认为空,而非 {@code null}。
 */
private ArrayList<File> countFiles = new ArrayList<>();

/** 表示是否有命令参数 {@code -c},即是否需要计算字符数,默认为否 */
private boolean requestChar = false;

/** 表示是否有命令参数 {@code -w},即表示是否需要计算单词数,默认为否 */
private boolean requestWord = false;

/** 表示是否有命令参数 {@code -l},即是否需要计算行数,默认为否 */
private boolean requestLine = false;

/**
 * 表示是否有命令参数 {@code -o},即是否需要打印到指定的输出文件,默认为否
 */
private boolean requestOut = false;

/**
 * 指定的输出文件名
 * {@code -o} 命令参数和 outputFileName 必须成对出现
 */
private String outputFileName = null;

/**
 * 表示是否有命令参数 {@code -s},默认为否
 * 是否需要递归处理目录下符合条件的文件
 */
private boolean requestRecur = false;
/**
 * 需要递归处理的文件夹名
 */
private String folderPath = null;

/**
 * 表示是否有有命令参数 {@code -a},默认为否
 * 是否需要返回更复杂的数据(代码行 / 空行 / 注释行)
 */
private boolean requestMore = false;

/**
 * 表示是否有扩展功能 {@code -e},默认为否
 * {@code -e stopList.txt} 是否需要提供文件,不计入单词统计
 * {@code -e} 命令参数和 stopList.txt 必须成对出现
 */
private boolean requestIgnore = false;

/**
 * 指定的停用词表名
 */
private String stopListName = null;

/** 停用词表 */
private HashSet<String> stopList = null;

依赖方法说明

/**
 * 判断是否是各种单词分隔符 (eg. ',', ' ', '\t', '\n')
 * @param ch
 *        被检测的字符
 * @return {@code true} 如果 {@code ch} 是规定的分隔符, 否则返回 {@code false}
 */
private static boolean isWordSep(char ch) {
    return (ch == ',' || ch == ' ' || ch == '\t' || ch == '\n');
}

/**
 * 判断是否是各种空白字符 (eg. ' ', '\t', '\n')
 * @param ch
 *        被检测的字符
 * @return {@code true} 如果 {@code ch} 是规定的空白字符, 否则返回 {@code false}
 */
private static boolean isBlank(char ch) {
    return (ch == ' ' || ch == '\t' || ch == '\n');
}

/**
 * 判断文件名是否和要求的正则表达式相匹配
 *
 * @param fileName
 *        被判断的文件名
 * @param fileRegEx
 *        正则匹配格式
 *        eg. "(\\w)+.txt" *.txt, "(\\w)+.[ch]" *.c or *.h
 * @return 如果文件名和要求的正则表达式匹配,返回 {@code true} ,否则 {@code false}
 */
private static boolean isFileMatch(String fileName, String fileRegEx) {
    // .c & .h 文件的文件名正则表达式的模式
    Pattern countablePattern = Pattern.compile(fileRegEx);
//        System.out.println(fileName);
    // 字符串为 null 或为空显然不匹配
    if (fileName == null || fileName.equals("")) {
        return false;
    }
    Matcher fileNameMacher = countablePattern.matcher(fileName);
    return fileNameMacher.matches();
}

/**
 * 递归遍历获取一个路径下的所有符合要求的文件,
 * 匹配 {@code .c} 和 {@code .h} 文件,
 * 匹配只有 {@code _A-Za-z0-9} 文件夹名。
 * @param pathName
 *        需要遍历的路径
 * @param fileList
 *        遍历获得的所有文件
 */
public static void getAllFile(String pathName, ArrayList<File> fileList) {
    File tempFile = new File(pathName);
    if (!tempFile.exists()) {
        return;
    }
    else if (tempFile.isFile()
            && tempFile.getName().matches(COUNTABLE_FILE_REG_EX)) {
        fileList.add(tempFile);
        return;
    } 
    else if (tempFile.isDirectory() 
            && tempFile.getName().matches(FOLDER_REG_EX)) {
        File[] files = tempFile.listFiles();
        if (files != null) {
            for (File f : files) {
                // 一个目录下有多个文件,在一次调用中全部处理
                // 以免不必要的递归
                if (tempFile.isFile()
                        && tempFile.getName().matches(COUNTABLE_FILE_REG_EX)) {
                    fileList.add(tempFile);
                    
                }
                else if (tempFile.isDirectory()
                        && tempFile.getName().matches(FOLDER_REG_EX)) {
                    getAllFile(f.getPath(), fileList);
                }
            }
        }
    }
}

功能方法说明

篇幅原因,只说明了部分方法。详情可见 Javadoc 和源代码。

/**
 * 返回一个由给定的停用词表生成的 {@code HashSet<String>} 对象。
 * @param pathName 给定的停用词表的路径名
 * @return {@code null} 如果文件不存在或者不是 .txt 格式。{@code HashSet} 的对象,
 *         如果文件存在且合法,但是为空,返回一个空的 {@code HashSet} 对象,否则返回
 *         一个非空的 {@code HashSet} 对象。
 *
 * @since JDK1.7 使用了该版本才引进 {@code try with resources} 的语法
 *
 */
private HashSet<String> getStopList(String pathName) {
    HashSet<String> stopList = new HashSet<>();
    File stopFile = new File(pathName);

    // 1. 如果文件不存在或者不是 .txt 格式,返回 null
    if (!stopFile.exists() || !isFileMatch(stopFile.getName(), TXT_REG_EX)) {
        return null;
    } else {
        try (Scanner sc = new Scanner(stopFile)){
            // 2.1 如果文件存在且合法,但是为空,返回一个空的 HashSet
            //     这区别于情况 1,逻辑意义不同
            // 2.2 否则返回一个非空的 HashSet
            while (sc.hasNextLine()) {
                String line = sc.nextLine();
                String stopWords[] = line.split(" ");
                for (String word : stopWords) {
                    stopList.add(word);
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        return stopList;
    }
}

/**
 * 将统计出来的信息写入指定文件。
 * <br>
 * 使用了 {@code try with resources} 的特性,需要 JDK1.7+ 的支持。
 * @since JDK1.7
 * @param outputName 指定的文件路径
 */
public void writeOutput(String outputName) {
    ArrayList<String> fileCounters = new ArrayList<>();
    for (File file : this.countFiles) {
        String aFileCounter = "";
        if (requestChar) {
            aFileCounter += ("字符数:" + this.countChar(file) + "\n");
        }
        if (requestWord) {
            aFileCounter += ("单词数:" + this.countWord(file) + "\n");
        }
        if (requestLine) {
            aFileCounter += ("行数:" + this.countLine(file) + "\n");
        }
        if (requestMore) {
            long[] counts = this.countMoreInfo(file);
            aFileCounter += ("代码行/空行/注释行:" + counts[0] + "/" +
                                  counts[1] + "/" + counts[2] + "\n");
        }
        fileCounters.add(aFileCounter);
    }
    try (PrintWriter output = new PrintWriter(outputName, "UTF-8")) {
        for (String str : fileCounters) {
            output.print(str);
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
}

测试设计过程

测试原理

采用课程中介绍的白盒测试用例设计方法来设计测试用例,在测试用例说明中都有具体的体现:

  1. 精确到函数来设计用例
  2. 测试边界
  3. 路径测试

单元测试

单元测试的结构

创建单元测试函数的主要步骤:

  1. 设置数据(一个假想的正确的E-mail地址);
  2. 使用被测试类型的功能(用E-mail地址来创建一个User类的实体);
  3. 比较实际结果和预期的结果(Assert.IsTrue(target!= null);)

记录测试用例

  1. 使用易读的测试用例名,见名知意。如最小/空测试 test/testCases/empty.c
  2. 在项目的 README.md 中详细说明。如最大单文件测试,来自 Lua 语言源代码中的解析器 test/testCases/lparser.c
  3. 在项目的 commit 中也写了测试概要

程序高风险说明

  1. 读写文件权限问题,如果在类 Unix 环境下,可能无写权限,导致 IOException
  2. 递归处理文件夹问题,由于使用了递归方法处理文件夹,如果路径过深,可能导致栈溢出。
  3. 文件创建失败,或不存在异常处理等

测试用例说明

下列共 14 个测试文件,基本覆盖了所有规定的、可能的路径。

  • test
    • testCases
      • 1 emptyTest.c // 测试空文件 最小测试
      • 2 hello.c // 普通程序测试
        // 3 业界代码实况测试——Lua 语言的 parser 代码,50 KB,接近 2000 行
      • 3 lparser.c
      • 4 quoteTest1.c // 4 单独成行的注释测试
      • 5 quoteTest2.c // 5 多行代码注释
      • 6 quoteInString.c // 6 当 注释符号出现在字符串中,并不认为它是注释
      • 7 newLine.c // 7 单个空行测试
      • 8 newLines.c // 8 多个空行测试
      • 9 crlfLfF.c // 9 跨平台换行测试
      • 10 stopEmpty.c // 10 停用词表为空测试
      • 11 stopList.c // 11 停用词表非空测试
      • 12 iLegalStopList 12 非法停用词表测试
      • 13 sqllite.txt // 13 ASCII C 工业项目下载地址 SQLLite 获取一个路径下的所有文件测试
      • 14 deepRecur // 14 深目录测试

参考资料

  1. Java 白皮书 version 9 & version 10
  2. 《Java 测试驱动测试》—— 图灵出版社
  3. Java SE API
  4. 邹欣老师关于单元测试的博客
posted @ 2018-03-20 22:40  VectorLu  阅读(367)  评论(2编辑  收藏  举报