亚马逊AWS官方博客

HAQM Q 从入门到精通 – 测试与重构

HAQM Q Developer 是亚马逊推出的一个专为专业开发人员设计的人工智能助手,旨在提升代码开发和管理效率。其主要功能包括代码生成、调试、故障排除和安全漏洞扫描,提供一站式代码服务。

众所周知,在软件开发领域,测试代码是软件成功的重要基石。它确保应用程序是可靠的,符合质量标准,并且按预期工作。自动化软件测试有助于及早发现问题和缺陷,减少对最终用户体验和业务的影响。此外,测试本身就是一个最可靠的文档,把每个细分功能进行了明确。同时,它也是一个细化到最小功能单元的安全网,可以防止代码随时间变化而发生回归(Regression)问题。

因此,在现代软件工程实践中,经常会看到书写 100 行功能代码的同时,开发人员会同时书写 1.5 倍甚至更多的测试代码来保证功能的正确性。另外,在知名的 GitHub 开源工程中,当贡献者开启 Pull Request 时,系统就会自动运行开发者自己编写的单元测试程序。单元测试程序的好坏和执行结果,都是评审人重要的审查标准。

在这篇博客文章中,我们将展示如何通过集成像 HAQM Q Developer 这样的智能 GenAI 工具来为单元测试,自动化测试场景快速、准确地生成测试用例,并以一些实际的代码用例,来描述测试的最佳实践原则,以及 HAQM Q 如何能够在其中扮演重要的角色。

不可测试的代码

当我们追求整洁、优雅的代码的同时,像硬币总会有另一面一样,世界上总会存在着混乱,风格怪异,难以测试的“意大利面条”式的代码。

什么是“意大利面条”式的代码呢?如下所示:

class Printer:
    def __init__(self):
        self.printer_name = "Default Printer"

    def print_document(self, content):
        print(f"Printing with {self.printer_name}: {content}")
        # 模拟打印操作
        with open("print_history.log", "a") as f:
            f.write(f"Printed: {content}\n")

class Database:
    def __init__(self):
        self.connection = "Database Connection String"

    def save_data(self, data):
        print(f"Saving to database: {data}")
        # 模拟数据库操作
        return True

    def get_data(self, query):
        # 模拟从数据库获取数据
        return f"Data for query: {query}"

class ReportGenerator:
    def __init__(self):
        # 直接在构造函数中实例化依赖,这是不好的实践
        self.printer = Printer()
        self.database = Database()

    def generate_monthly_report(self, month):
        # 违反单一职责原则:既处理数据,又负责打印
        print("Starting report generation...")

        # 直接访问数据库
        sales_data = self.database.get_data(f"SELECT * FROM sales WHERE month = {month}")

        # 直接处理文件
        with open(f"report_{month}.txt", "w") as f:
            f.write(f"Sales Report for Month: {month}\n")
            f.write(str(sales_data))

        # 直接打印
        self.printer.print_document(f"Monthly Report - {month}")

        # 再次访问数据库保存记录
        self.database.save_data({
            "report_type": "monthly",
            "month": month,
            "status": "completed"
        })

    def generate_daily_report(self, date):
        # 类似的混乱逻辑
        daily_data = self.database.get_data(f"SELECT * FROM daily_sales WHERE date = {date}")

        # 直接文件操作
        with open(f"daily_report_{date}.txt", "w") as f:
            f.write(f"Daily Report for: {date}\n")
            f.write(str(daily_data))

        # 直接打印
        self.printer.print_document(f"Daily Report - {date}")

        # 保存状态到数据库
        self.database.save_data({
            "report_type": "daily",
            "date": date,
            "status": "completed"
        })

# 使用示例
if __name__ == "__main__":
    report_gen = ReportGenerator()
    report_gen.generate_monthly_report("2023-12")
    report_gen.generate_daily_report("2023-12-26")

这段代码看上去很简单,主对象 report_gen,依赖于 printer,和 database 对象来进行打印和报表保存。甚至为了更快地得到代码所要展现的信息,可以让 HAQM Q 帮你绘制一个文字风格的时序图。如下图所示:

真的很棒!基本都不用看代码,就能知道它在做什么了,这是一个对开发者很实用的功能。

把代码执行一下,它的输入如下图所示。

接下来,让 HAQM Q 来解释一下这段代码,看看它能否找到一些问题?在 HAQM Q Chat 窗口里,输入最关注的问题,如“Can you help me find issues with the code in test.py, from design and testability perspective? don’t give suggestion, just list all of issues.”。HAQM Q 的回复如下图所示。

HAQM Q 很轻松地找到了相关的核心问题,问题不少,但本文只挑选设计和测试方面的问题如下:

  • 紧耦合
    • ReportGenerator 直接实例化了 printer和database。
    • 直接实例化导致 ReportGenerator 无法被隔离。
    • 因此无法注入 mock 的 printer 和 database 来对 ReportGenerator 进行测试。
  • 违背了单一职责原则
    • ReportGenerator 身兼数职,不但要做数据库操作和文件操作,连报表生成也都一起包揽。
    • Printer 类里包含了打印和日志两项职能。
    • Database 类包含了读写两类操作。
  • 直接依赖外部实体
    • Printer 类直接文件操作。
    • ReportGenerator 类直接进行文件操作。
    • Database 类的直接操作。
  • 缺少接口抽象
    • Printer 类没有对应的接口抽象。
    • Database 类没有对应的接口抽象。
    • 组件之间交互时,没有契约。

显然,这种无法做单元测试的代码,不但很难保证质量,维护起来也很麻烦,复用性也很差。

使用 HAQM Q Developer 重构代码

稍微改动一下 HAQM Q 的提示词,允许它给我们提提建议。在 HAQM Q Chat 窗口里,再次输入问题“/dev Can you help me refactor these issues? Please focus on design and testing related issues only”。HAQM Q 的回复如下图所示。

在接受了所有代码变更之后,HAQM Q Developer 为我们创建了一个主程序文件,和一个测试代码文件。

其中,test_report_generator.py 是一个测试代码文件。它的代码如下所示。

import unittest
from unittest.mock import MagicMock, patch
from pathlib import Path
from refactored_test import (
    ReportGenerator,
    Printer,
    Database,
    ReportWriter,
    ReportConfig
)

class TestReportGenerator(unittest.TestCase):
    def setUp(self):
        self.printer = MagicMock()
        self.database = MagicMock()
        self.report_writer = MagicMock()
        self.report_generator = ReportGenerator(
            printer=self.printer,
            database=self.database,
            report_writer=self.report_writer
        )

    def test_generate_monthly_report(self):
        # Arrange
        month = "2023-12"
        test_data = "Test sales data"
        self.database.get_data.return_value = test_data

        # Act
        self.report_generator.generate_monthly_report(month)

        # Assert
        self.database.get_data.assert_called_once()
        self.report_writer.write_report.assert_called_once()
        self.printer.print_document.assert_called_once_with(f"Monthly Report - {month}")
        self.database.save_data.assert_called_once()

    def test_generate_daily_report(self):
        # Arrange
        date_str = "2023-12-25"
        test_data = "Test daily data"
        self.database.get_data.return_value = test_data

        # Act
        self.report_generator.generate_daily_report(date_str)

        # Assert
        self.database.get_data.assert_called_once()
        self.report_writer.write_report.assert_called_once()
        self.printer.print_document.assert_called_once_with(f"Daily Report - {date_str}")
        self.database.save_data.assert_called_once()

class TestReportWriter(unittest.TestCase):
    def setUp(self):
        self.test_dir = Path("test_reports")
        self.config = ReportConfig(
            output_directory=self.test_dir,
            log_file=Path("test_logs/print_history.log")
        )
        self.report_writer = ReportWriter(self.config)

    def tearDown(self):
        # Clean up test files
        if self.test_dir.exists():
            for file in self.test_dir.glob("*"):
                file.unlink()
            self.test_dir.rmdir()

    def test_write_report(self):
        # Arrange
        filename = "test_report.txt"
        content = "Test report content"

        # Act
        self.report_writer.write_report(filename, content)

        # Assert
        report_path = self.test_dir / filename
        self.assertTrue(report_path.exists())
        self.assertEqual(report_path.read_text(), content)

if __name__ == '__main__':
    unittest.main()

主程序文件(refactored_test.py)代码如下。

from abc import ABC, abstractmethod
from typing import Protocol
from dataclasses import dataclass
import os
from datetime import date
from pathlib import Path

class PrinterInterface(Protocol):
    def print_document(self, content: str) -> None:
        ...

class DatabaseInterface(Protocol):
    def save_data(self, data: dict) -> bool:
        ...
    def get_data(self, query: str) -> str:
        ...

@dataclass
class ReportConfig:
    output_directory: Path
    log_file: Path

class Printer:
    def __init__(self, name: str = "Default Printer", log_file: Path = Path("print_history.log")):
        self.printer_name = name
        self.log_file = log_file

    def print_document(self, content: str) -> None:
        print(f"Printing with {self.printer_name}: {content}")
        # Safely handle file operations
        self.log_file.parent.mkdir(parents=True, exist_ok=True)
        with open(self.log_file, "a") as f:
            f.write(f"Printed: {content}\n")

class Database:
    def __init__(self, connection_string: str = "Database Connection String"):
        self.connection = connection_string

    def save_data(self, data: dict) -> bool:
        print(f"Saving to database: {data}")
        return True

    def get_data(self, query: str) -> str:
        return f"Data for query: {query}"

class ReportWriter:
    def __init__(self, config: ReportConfig):
        self.config = config

    def write_report(self, filename: str, content: str) -> None:
        filepath = self.config.output_directory / filename
        filepath.parent.mkdir(parents=True, exist_ok=True)
        with open(filepath, "w") as f:
            f.write(content)

class ReportGenerator:
    def __init__(
        self,
        printer: PrinterInterface,
        database: DatabaseInterface,
        report_writer: ReportWriter
    ):
        self.printer = printer
        self.database = database
        self.report_writer = report_writer

    def generate_monthly_report(self, month: str) -> None:
        print("Starting monthly report generation...")
        
        # Get data
        sales_data = self.database.get_data(f"SELECT * FROM sales WHERE month = {month}")
        
        # Generate report content
        report_content = self._format_monthly_report(month, sales_data)
        
        # Write report
        self.report_writer.write_report(f"report_{month}.txt", report_content)
        
        # Print report
        self.printer.print_document(f"Monthly Report - {month}")
        
        # Log completion
        self._log_report_completion("monthly", month=month)

    def generate_daily_report(self, date_str: str) -> None:
        print("Starting daily report generation...")
        
        # Get data
        daily_data = self.database.get_data(f"SELECT * FROM daily_sales WHERE date = {date_str}")
        
        # Generate report content
        report_content = self._format_daily_report(date_str, daily_data)
        
        # Write report
        self.report_writer.write_report(f"daily_report_{date_str}.txt", report_content)
        
        # Print report
        self.printer.print_document(f"Daily Report - {date_str}")
        
        # Log completion
        self._log_report_completion("daily", date=date_str)

    def _format_monthly_report(self, month: str, data: str) -> str:
        return f"Sales Report for Month: {month}\n{data}"

    def _format_daily_report(self, date_str: str, data: str) -> str:
        return f"Daily Report for: {date_str}\n{data}"

    def _log_report_completion(self, report_type: str, **kwargs) -> None:
        completion_data = {
            "report_type": report_type,
            "status": "completed",
            **kwargs
        }
        self.database.save_data(completion_data)

# Example usage:
def create_report_system(
    output_dir: str = "reports",
    log_file: str = "logs/print_history.log"
) -> tuple[ReportGenerator, PrinterInterface, DatabaseInterface, ReportWriter]:
    config = ReportConfig(
        output_directory=Path(output_dir),
        log_file=Path(log_file)
    )
    
    printer = Printer(log_file=config.log_file)
    database = Database()
    report_writer = ReportWriter(config)
    report_generator = ReportGenerator(printer, database, report_writer)
    
    return report_generator, printer, database, report_writer

重构后的代码,主要的变更和好处如下:

  • 定义了接口协议类
    • PrinterInterface 定义了打印机的接口,而 Printer 是它的一个具体的实现。给予这种设计,可以有更多的实现,比如 pdf 打印机,激光打印机等等。
    • DatabaseInterface 定义数据库的接口,而 Database 是它的一个具体的实现,基于这种设计,可以有更多的实现,比如内存型数据库、文件型数据库、关系型数据库等等。
    • 可以很容易地升级/替换 Printer 和 Database 的实现代码,而不影响 ReportGenerator 本身的功能。
  • 增加了系统的契约
    • ReportGenerator 不依赖于具体的实现,而是依赖于契约(接口)
    • 基于接口的设计,可以非常容易地置换为 Mock 的实现,来进行充分的测试。
    • 有了契约,就有了可测试性。

一图胜千言,为了更好地理解重构带来的变化,可以再次让 HAQM Q Developer 来图文结合地进行描述和总结,输入提示词,“Can you show the importance of introducing abstract interface than before in ASCII-style diagram?”,HAQM Q Developer 将用文字版图形来描述重构里引入抽象接口起到的关键作用。

通过简单/直接的自然语言交互,在分钟级别的时间范围内,HAQM Q Developer 便完成了对不良设计的重构,把遵循良好设计的代码呈现在开发者的面前。

快捷的单元测试生成方式

如果开发者当下的任务是节约编写单元测试的精力和时间,除了使用/dev 来进行代码重构外,HAQM Q Developer 提供了专门的/test 命令。

打开要编写单元测试的文件,在 HAQM Q Developer 的 Chat 窗口里输入 /test,即可开始编写单元测试代码,如下图所示。

单元测试代码创建中,会显示进度。如下图所示。

最终,和使用/dev 一样,HAQM Q Developer 不会直接变更代码,而是给出一个临时的变更结果给开发者,开发者可以以 diff 的形式进行查看,并决定是接受,还是拒绝。

就是如此简单,开发者就可以完成之前繁琐的创建单元测试的工作。

不仅如此,当业务代码不断随着市场需求发生频繁变化的时候,开发者将可以随时以智能化、自动化的方式,让 HAQM Q Developer 协助生成最新的单元测试代码,让单元测试能够提供精确代码质量保证的同时,不再产生高昂的维护代价!

最后

本文以一个“意大利面条式”的,充满了不良设计的代码为样例,展示了 HAQM Q Developer 如何能够以简单/精炼的自然语言交互的方式,短时间内帮助开发者完成代码重构和自动化测试用例的编写,在确保代码质量的同时,大大降低了测试代码的维护成本。


*前述特定亚马逊云科技生成式人工智能相关的服务仅在亚马逊云科技海外区域可用,亚马逊云科技中国仅为帮助您了解行业前沿技术和发展海外业务选择推介该服务。

本篇作者

Kelvin Guo

亚马逊云科技资深解决方案架构师。主要技术方向为 MLOps、DevOps、容器、数据分析。20+年软件开发、项目管理、敏捷思想落地、工程效能咨询和落地经验。

汪其香

亚马逊云科技解决方案架构师,负责基于亚马逊云科技云服务的架构咨询和设计实现,具有丰富的解决客户实际问题的经验,同时热衷于生成式 AI、深度学习的研究与应用。