1. 项目概述从断言到单元测试构建健壮代码的基石最近在带新人做代码评审发现一个挺普遍的现象很多刚入行的朋友甚至一些工作一两年的同事对C/C里的assert断言和单元测试的理解还停留在“知道有这么个东西”的层面。要么是代码里到处塞满了assert把本该由错误处理逻辑接管的条件也交给它要么是单元测试写得零零散散测了等于没测项目一上线各种边界条件下的bug就冒出来了。这让我想起自己刚工作时踩过的坑所以今天想结合我这些年做系统开发、性能调优和团队技术建设的经验好好聊聊assert和单元测试到底该怎么用它们之间到底是什么关系以及如何围绕它们构建一套有效的代码质量防线。这不仅仅是写几个测试用例那么简单它关乎你对程序健壮性、可维护性乃至整个工程化开发流程的理解。无论你是正在啃《C Primer Plus》的学生还是已经在一线写业务逻辑的工程师希望这篇长文能给你带来一些实实在在的启发和可落地的操作指南。2. 断言assert的深度解析不仅仅是“报错”断言英文叫assert在C/C标准库中它是以一个宏的形式存在的定义在assert.h或cassert头文件里。它的核心作用是在代码中插入一个“检查点”当程序运行到这个点时会验证某个条件表达式是否为真。如果为真程序继续默默执行如果为假它就会“断言失败”通常会导致程序立即终止并打印出错误所在的文件名、行号以及那个失败的条件表达式。2.1 assert的核心机制与底层原理很多人用assert但未必清楚它背后是怎么工作的。我们来看一个最简单的例子#include assert.h #include stdio.h int divide(int a, int b) { // 使用assert检查除数不为0 assert(b ! 0); return a / b; } int main() { int result divide(10, 2); printf(Result: %d\n, result); // 这行会触发断言失败 result divide(10, 0); return 0; }当你用调试模式通常未定义NDEBUG宏编译并运行上述程序第二次调用divide(10, 0)时程序会崩溃并输出类似这样的信息Assertion failed: b ! 0, file test.c, line 6这个行为的背后是assert宏的典型实现简化版#ifdef NDEBUG #define assert(expression) ((void)0) #else #define assert(expression) \ (void)( (!!(expression)) || (__assert_fail(#expression, __FILE__, __LINE__, __func__), 0) ) #endif关键点拆解NDEBUG宏的开关作用这是assert最精妙的设计。当你在编译时定义了NDEBUG宏例如通过gcc -DNDEBUG所有的assert语句都会被预处理器替换成((void)0)也就是一个什么都不做的空操作。这意味着在发布版本Release Build中assert代码会被完全移除不会产生任何运行时开销。这是assert与普通错误处理如if判断返回错误码的本质区别之一。表达式求值与短路逻辑宏展开后是一个逻辑或||操作。!!(expression)将表达式结果布尔化。如果expression为真非零由于短路特性||后面的__assert_fail函数调用就不会执行。如果为假0则程序必须执行__assert_fail来求值整个表达式这个函数负责打印错误信息并终止程序通常是调用abort()。信息捕获#expression将表达式本身转换为字符串__FILE__、__LINE__和__func__是预定义的宏分别捕获文件名、行号和函数名。这为调试提供了最直接的上下文。注意assert是用来处理“不应该发生”的情况即程序逻辑上的bug。比如函数参数前置条件、后置条件或者数据结构在某个时刻必须满足的不变性。它不是用来处理可预期的运行时错误比如文件打开失败、网络连接超时、用户输入格式错误等。后者应该使用错误码、异常等机制。2.2 assert的典型应用场景与误用避坑在实际编码中如何恰到好处地使用assert下面是一些经过验证的最佳实践和常见陷阱。场景一验证函数参数前置条件这是assert最经典的用法。在函数入口处检查传入的参数是否满足函数正常工作的基本假设。// 好的例子检查指针非空当NULL是非法输入时 void process_buffer(char* buffer, size_t size) { assert(buffer ! NULL); assert(size 0 size MAX_BUFFER_SIZE); // ... 处理逻辑 } // 需要注意的例子如果NULL是允许的并代表特殊含义则不应使用assert void optional_log(const char* message) { // message为NULL时选择静默这是设计的一部分不是bug if (message NULL) { return; } // ... 记录日志 }场景二验证函数结果或状态后置条件与不变性在函数返回前或复杂操作的中间步骤验证结果是否符合预期。// 验证后置条件分配的内存应对齐到特定边界 void* aligned_alloc(size_t size, size_t alignment) { void* ptr _internal_alloc(size, alignment); // 确保我们实现的内存分配器确实做到了对齐 assert(((uintptr_t)ptr (alignment - 1)) 0); return ptr; } // 验证数据结构不变性例如在二叉搜索树操作后 void bst_insert(Node** root, int key) { // ... 插入逻辑 #ifndef NDEBUG // 仅在调试时进行昂贵的完整性检查 assert(bst_validate(*root)); // 假设bst_validate会遍历整棵树检查有序性 #endif }常见误用与避坑指南在assert中执行有副作用的操作这是绝对要避免的。// 错误发布版本中NDEBUG定义后函数调用会被移除 assert((p malloc(size)) ! NULL); // 正确做法 p malloc(size); assert(p ! NULL);用assert替代所有错误处理比如从配置文件读取一个数值如果格式错误这是一个可预期的运行时错误应该解析并返回错误而不是用assert直接让程序崩溃。忽略assert在发布版本中消失的事实这意味着你不能依赖assert来做任何程序正确性所必须的检查。所有在Release版本中仍需存在的安全检查必须用if语句实现。实操心得我习惯在项目的CMakeLists.txt或Makefile中明确区分构建类型。调试构建Debug绝不定义NDEBUG让assert充分发挥“代码警察”的作用。而发布构建Release则一定会加上-DNDEBUG确保性能最优。在关键的核心算法或数据结构模块中我甚至会写一些只在Debug模式下编译的、非常耗时的完整性验证函数通过assert调用它们在开发阶段最大限度地捕捉深藏的逻辑错误。3. 单元测试超越“测试”的代码设计工具如果说assert是代码内部的即时检查员那么单元测试就是代码外部的专职质检员。单元测试的目标是针对软件中的最小可测试单元在C/C中通常是函数或类进行正确性检验。但它的价值远不止于“找bug”。3.1 单元测试的核心价值与框架选择很多团队推行单元测试困难是因为只把它当成一项额外的、耗费时间的任务。实际上良好的单元测试能带来多重收益验证功能正确性这是最基本的功能确保代码在给定输入下产生预期输出。驱动更好的设计难以测试的代码往往也是耦合度高、职责不清的代码。编写测试会迫使你思考接口设计、依赖注入从而得到更模块化、更松耦合的实现。充当活文档一套好的测试用例比任何注释都更能说明函数在各种边界情况下的行为。支持安全重构当你想优化或修改代码时完善的测试套件能给你充足的信心确保修改没有破坏现有功能。对于C/C项目选择合适的测试框架是第一步。市面上主流的有Google Test (gtest)目前最流行、功能最全面的C测试框架之一。提供丰富的断言宏、测试夹具、死亡测试、参数化测试等特性社区活跃。Catch2一个“现代”的C测试框架只需要单个头文件语法更简洁直观。CppUnit仿照JUnit的经典框架历史悠久但在易用性上不如前两者。Unity/CMock在嵌入式C开发领域非常流行的一套框架轻量级。对于新项目我通常推荐Google Test。它功能强大与CMake等构建工具集成好网上资料丰富。下面以Google Test为例展开。3.2 使用Google Test编写有效的测试用例安装Google Test例如通过vcpkg、conan或直接源码集成后我们来看一个简单的测试例子。假设我们有一个math_utils.h头文件// math_utils.h #ifndef MATH_UTILS_H #define MATH_UTILS_H int add(int a, int b); int subtract(int a, int b); // 一个计算圆面积的函数当半径非法时返回-1.0这是一种错误处理方式 double circle_area(double radius); #endif对应的测试文件math_utils_test.cpp可能如下#include gtest/gtest.h #include math_utils.h // 1. 测试正常功能 TEST(MathUtilsTest, AddPositiveNumbers) { EXPECT_EQ(add(1, 2), 3); EXPECT_EQ(add(0, 0), 0); EXPECT_EQ(add(-1, 5), 4); // 也测试正负混合 } TEST(MathUtilsTest, Subtract) { EXPECT_EQ(subtract(5, 3), 2); EXPECT_EQ(subtract(0, 0), 0); EXPECT_EQ(subtract(3, 5), -2); } // 2. 测试边界和错误处理 TEST(MathUtilsTest, CircleAreaValidRadius) { // 使用EXPECT_NEAR进行浮点数比较第三个参数是容差 EXPECT_NEAR(circle_area(1.0), 3.1415926535, 1e-10); EXPECT_NEAR(circle_area(2.5), 2.5*2.5*3.1415926535, 1e-10); } TEST(MathUtilsTest, CircleAreaInvalidRadius) { // 测试非法输入负数是否按设计返回错误码 EXPECT_EQ(circle_area(-1.0), -1.0); EXPECT_EQ(circle_area(0.0), -1.0); // 假设0半径也被认为是非法的 } // 3. 使用测试夹具Fixture组织共享设置 class MathUtilsComplexTest : public ::testing::Test { protected: void SetUp() override { // 在每个测试用例开始前执行可用于初始化复杂对象 shared_value 42; } void TearDown() override { // 在每个测试用例结束后执行用于清理资源 } int shared_value; }; TEST_F(MathUtilsComplexTest, FixtureUsageExample) { // 可以访问Fixture中设置的成员变量 EXPECT_EQ(add(shared_value, 10), 52); }关键点解析TEST宏定义了一个独立的测试用例。第一个参数是测试套件名第二个是测试用例名它们共同组成一个唯一的测试标识。断言宏EXPECT_EQ、EXPECT_NEAR等。EXPECT_*在失败时测试会继续执行ASSERT_*在失败时会立刻终止当前测试用例。通常优先使用EXPECT_*以便一个测试中能发现多个问题。测试夹具当多个测试用例需要相同的配置或数据初始化时可以创建一个继承自testing::Test的类在SetUp和TearDown中准备和清理环境。使用TEST_F来运行这些测试。3.3 单元测试的实操策略与心得写单元测试不是简单地给每个函数写一个TEST。有效的测试需要策略测试什么—— 关注行为而非实现不要测试私有函数除非不得已而是通过公有接口测试类的行为。测试应该对内部实现的变化有足够的弹性。如果你发现一修改实现测试就大面积失败那说明你的测试可能过度耦合了实现细节。如何组织测试代码—— 保持测试的独立性与可读性独立性每个测试用例应该独立设置环境、执行、验证、清理。不能依赖其他测试的执行顺序或留下的全局状态。Google Test默认会打乱测试顺序来确保这一点。可读性测试用例的名称应该清晰地表达其意图如EmptyStackThrowsOnPop比TestPop1好得多。使用ASSERT/EXPECT时如果失败信息不够清晰可以使用操作符添加自定义输出如EXPECT_EQ(result, expected) Failed with input: input;。处理外部依赖模拟与打桩这是单元测试的核心难点。如果你的函数依赖数据库、网络、文件系统或复杂的第三方库直接测试会变得缓慢、不稳定。这时需要使用模拟对象或打桩。对于C通常通过函数指针依赖注入在测试中替换为桩函数。对于C可以利用虚函数和多态。定义接口抽象类生产代码使用真实实现测试代码使用模拟实现。Google Mock是Google Test的配套模拟框架可以方便地创建模拟对象并设置期望。// 示例一个依赖网络服务的类 class NetworkService { public: virtual ~NetworkService() default; virtual std::string fetchData(const std::string url) 0; }; class MyProcessor { public: MyProcessor(NetworkService* service) : service_(service) {} bool process() { auto data service_-fetchData(http://example.com); return !data.empty(); } private: NetworkService* service_; }; // 测试中使用Mock #include gmock/gmock.h class MockNetworkService : public NetworkService { public: MOCK_METHOD(std::string, fetchData, (const std::string url), (override)); }; TEST(MyProcessorTest, ProcessSuccessOnNonEmptyData) { MockNetworkService mock; MyProcessor processor(mock); // 设置期望当fetchData被调用时返回test data EXPECT_CALL(mock, fetchData(http://example.com)) .WillOnce(::testing::Return(test data)); // 验证process返回true EXPECT_TRUE(processor.process()); }测试覆盖率只是一个参考指标追求100%的覆盖率通常不现实且性价比低。应该更关注核心逻辑、复杂分支和边界条件的覆盖。工具如gcov、llvm-cov可以帮助生成覆盖率报告用于发现测试盲区而不是作为终极目标。踩坑实录早期我曾为了追求覆盖率写了很多测试去覆盖简单的getter/setter方法或者是一些显而易见正确的初始化代码浪费了大量时间。后来我调整了策略采用风险驱动测试优先为那些包含复杂算法、条件分支多、修改频繁、或者一旦出错后果严重的模块编写密集的测试。对于简单的数据容器或胶水代码适当降低测试优先级。4. assert与单元测试的共生关系构建防御性编程体系现在我们来回答核心问题assert和单元测试到底是什么关系它们是对立的吗恰恰相反它们是互补的、在不同层次上守护代码质量的兄弟。4.1 职责划分运行时检查 vs 开发时验证我们可以用一个简单的表格来厘清它们的职责特性assert单元测试执行时机程序运行时当执行到该语句时。代码编译后、运行前由开发者或CI系统主动触发。检查目标程序内部的不变性条件Invariants。 “这个条件在此时此地必须为真否则就是程序有bug。”代码单元的外部行为Behavior。 “给定这些输入函数应该返回这些输出。”作用范围微观的、局部的、代码执行路径上的一个点。相对宏观的、针对一个函数或模块的完整功能验证。发布版本通常被NDEBUG宏移除不存在。测试代码不应被链接到最终发布产品中。反馈对象开发者调试时。开发者、代码评审者、持续集成系统。主要目的快速失败在bug发生现场立即暴露问题便于调试定位。预防回归确保代码修改不会破坏已有功能文档化行为。4.2 协同工作模式一个实战案例假设我们在实现一个简单的内存池分配器。让我们看看两者如何协同工作。// memory_pool.h class MemoryPool { public: explicit MemoryPool(size_t block_size, size_t pool_size); ~MemoryPool(); void* allocate(); void deallocate(void* ptr); size_t available() const; private: struct Block { /* ... */ }; Block* free_list_; size_t block_size_; size_t total_blocks_; size_t used_blocks_; // 内部不变性条件free_list_要么为空要么指向一个有效的空闲块链表 // used_blocks_ 空闲块数 total_blocks_ }; // memory_pool.cpp #include cassert void* MemoryPool::allocate() { // 断言1检查类内部状态不变性调试版本 assert((free_list_ nullptr) || (free_list_-next ! free_list_)); // 防止自环等链表损坏 if (free_list_ nullptr) { return nullptr; // 内存耗尽返回空指针这是可预期的运行时状态不是bug } Block* allocated free_list_; free_list_ free_list_-next; used_blocks_; // 断言2检查后置条件 assert(used_blocks_ total_blocks_); return static_castvoid*(allocated); } void MemoryPool::deallocate(void* ptr) { // 断言3检查输入有效性ptr必须来自本池且未被重复释放 assert(ptr ! nullptr); assert(is_pointer_from_pool(ptr)); // 假设有这个内部检查函数 assert(!is_pointer_already_free(ptr)); // 检查双重释放 Block* block static_castBlock*(ptr); block-next free_list_; free_list_ block; used_blocks_--; // 断言4释放后已用块数不能为负 assert(used_blocks_ total_blocks_); // 注意是小于因为刚释放一个 }// memory_pool_test.cpp #include gtest/gtest.h #include memory_pool.h #include vector TEST(MemoryPoolTest, AllocateDeallocateSingle) { MemoryPool pool(64, 10); // 块大小64字节共10块 void* ptr pool.allocate(); EXPECT_NE(ptr, nullptr); EXPECT_EQ(pool.available(), 9); // 假设available()返回空闲块数 pool.deallocate(ptr); EXPECT_EQ(pool.available(), 10); } TEST(MemoryPoolTest, ExhaustPool) { MemoryPool pool(64, 3); std::vectorvoid* ptrs; for (int i 0; i 3; i) { void* p pool.allocate(); EXPECT_NE(p, nullptr); ptrs.push_back(p); } // 池已耗尽再次分配应返回nullptr void* p pool.allocate(); EXPECT_EQ(p, nullptr); // 释放一个后再分配 pool.deallocate(ptrs.back()); ptrs.pop_back(); p pool.allocate(); EXPECT_NE(p, nullptr); } TEST(MemoryPoolTest, DeathTestOnDoubleFree) { // Google Test的“死亡测试”用于验证程序在特定条件下是否会“死”如断言失败 MemoryPool pool(64, 10); void* ptr pool.allocate(); pool.deallocate(ptr); // 期望第二次释放会触发assert失败导致程序终止 EXPECT_DEATH(pool.deallocate(ptr), .*is_pointer_already_free.*); // 注意死亡测试通常需要单独编译且只在未定义NDEBUG时有效 }协同关系分析单元测试验证公开契约测试用例AllocateDeallocateSingle和ExhaustPool验证了内存池对外宣称的功能——能分配、能释放、耗尽后返回空指针。这些是用户调用者关心的行为。assert守卫内部实现在allocate和deallocate函数内部的assert守卫的是实现细节中的不变性条件。例如链表不能损坏、块计数必须一致。这些是开发者维护代码内部正确性的工具。死亡测试连接两者DeathTestOnDoubleFree是一个有趣的结合点。单元测试通过EXPECT_DEATH断言预期程序在触发某个内部assert时会死亡。这实际上是用单元测试来验证我们的assert逻辑在特定非法操作下确实会起作用。这是一种对防御性编程代码本身的测试。4.3 集成到开发流程让防线自动化仅仅会写assert和单元测试还不够需要把它们融入到日常开发和团队协作流程中才能最大化价值。本地开发循环在编码时频繁运行相关的单元测试。许多现代IDE如CLion、VS Code with CMake Tools都支持检测到文件保存后自动运行测试。这能给你即时的反馈。预提交钩子在Git等版本控制系统中设置pre-commit钩子在提交代码前自动运行完整的测试套件或至少是变更相关的测试阻止有问题的代码进入仓库。持续集成在CI/CD流水线如Jenkins、GitLab CI、GitHub Actions中每次推送代码或发起合并请求时自动触发构建、运行所有单元测试、并生成测试覆盖率报告。这是保证主干代码质量的自动化闸门。代码评审在评审代码时不仅要看实现逻辑也要审阅新增代码对应的单元测试是否充分是否覆盖了正常路径、边界条件和错误场景。同时也可以检查关键算法内部是否添加了必要的assert来固化不变性假设。一个常见的CMake集成示例cmake_minimum_required(VERSION 3.16) project(MyProject) # 启用测试功能 enable_testing() # 添加主项目 add_library(my_library src/my_code.cpp) target_include_directories(my_library PUBLIC include) # 查找并链接GoogleTest find_package(GTest REQUIRED) # 添加测试可执行文件 add_executable(my_library_tests tests/my_code_test.cpp) target_link_libraries(my_library_tests my_library GTest::gtest GTest::gtest_main) # 将测试添加到CTest add_test(NAME MyLibraryTests COMMAND my_library_tests) # 可选添加覆盖率目标需要lcov/gcov if(CMAKE_BUILD_TYPE STREQUAL Debug AND COVERAGE) target_compile_options(my_library_tests PRIVATE --coverage -fprofile-arcs -ftest-coverage) target_link_options(my_library_tests PRIVATE --coverage) endif()5. 常见问题排查与高级技巧实录在实际项目中应用assert和单元测试总会遇到一些具体问题。这里记录一些我踩过的坑和总结的技巧。5.1 assert相关疑难杂症问题1assert在Release版本中“失效”但有些检查我确实希望在发布版中也保留怎么办这是对assert用途的混淆。如果你有一个检查在发布版本中也必须执行例如对来自不可信外部输入的安全性检查那么你应该使用一个始终编译的宏或函数而不是assert。// 自定义一个始终启用的“确保”宏 #define ENSURE(expr) \ do { \ if (!(expr)) { \ log_error(Assertion failed: %s, file %s, line %d, #expr, __FILE__, __LINE__); \ handle_error(); // 你的错误处理函数可能是返回错误码或安全终止 \ } \ } while(0) // 使用 void public_api(int* ptr) { ENSURE(ptr ! NULL); // Release版本也会检查 // ... 业务逻辑 }问题2assert导致程序直接abort()在生产环境服务中这可能太粗暴有办法获取更多信息吗标准的assert确实直接终止。在复杂系统中你可能希望记录更丰富的上下文如堆栈、线程ID、时间戳后再优雅降级或重启。可以重写__assert_fail函数Glibc或使用平台相关的信号处理SIGABRT来捕获断言失败事件。// Linux/GCC 示例自定义assert处理 #include cassert #include cstdlib #include iostream #include execinfo.h // 用于回溯 void custom_assert_fail(const char* expr, const char* file, int line, const char* func) { std::cerr Custom Assert Failed: expr std::endl; std::cerr Location: file : line in func std::endl; // 打印堆栈回溯仅Linux void* buffer[100]; int nptrs backtrace(buffer, 100); char** strings backtrace_symbols(buffer, nptrs); if (strings) { std::cerr Stack trace: std::endl; for (int i 0; i nptrs; i) { std::cerr strings[i] std::endl; } free(strings); } // 这里可以触发你的监控系统、保存核心转储等 std::abort(); // 最终还是终止或者根据情况选择longjmp跳转到恢复点 } // 重写__assert_fail注意这是Glibc特定函数 extern C void __assert_fail (const char* expr, const char* file, int line, const char* func) { custom_assert_fail(expr, file, line, func); }注意重写标准库函数是平台相关的行为需谨慎使用并做好兼容性处理。5.2 单元测试的进阶挑战问题1测试依赖于时间、随机数或全局状态导致测试结果不稳定“Flaky Tests”。不稳定的测试比没有测试更糟糕因为它会让人逐渐忽略测试失败。时间依赖将获取时间的函数如gettimeofday,std::chrono::system_clock::now抽象成接口在测试中注入一个模拟的、可控的“时钟”。随机数依赖使用确定的伪随机数种子。在测试开始时srand(0)或者将随机数生成器作为依赖注入。全局状态这是最难处理的。尽量通过重构减少对全局变量、静态变量的依赖。如果无法避免在测试的SetUp和TearDown中负责将全局状态重置到已知的初始值。问题2如何测试私有private成员函数原则上不直接测试私有函数而是通过公有接口来测试。但如果私有函数极其复杂且通过公有接口测试路径覆盖困难可以考虑以下方法权衡使用将私有函数改为保护protected或公有这破坏了封装是下策。使用friend类让测试类成为被测类的友元。这依然耦合了测试与实现。将复杂私有逻辑提取到一个独立的工具类或函数中这个新类/函数可以是公有的或内部的然后单独测试它。这是最推荐的方式符合单一职责原则。对于C语言可以在测试文件中使用#include “.c”源文件并借助static函数在单元内可见性的技巧但这也会增加耦合。问题3大型遗留代码库没有测试如何开始“罗马不是一天建成的”。可以从以下几点入手“钉钉子”策略当需要修改或修复某个模块的bug时首先为这个模块添加测试。这叫做“钉钉子”Pin用测试将当前可能有问题的行为固定下来然后再进行修改确保修改不会引入回归。从外围到核心先为最外层、依赖最少的工具函数、工具类编写测试。这些测试容易写能快速建立信心和测试基础设施。使用“接缝”和“链接期替换”对于依赖了难以模拟的第三方库的代码可以创建一层薄薄的包装接口。在测试时链接你自己的模拟实现在生产时链接真实的第三方库。这在C中通过函数指针表、在C中通过虚接口很容易实现。5.3 性能考量与平衡艺术assert的开销在Debug构建中assert的条件表达式求值会有开销。对于性能极其敏感的循环内部需要谨慎。一种模式是#ifndef NDEBUG #define DEBUG_ASSERT(expr) assert(expr) #else #define DEBUG_ASSERT(expr) ((void)0) #endif void critical_loop(int* data, size_t n) { for (size_t i 0; i n; i) { // 只在调试版本进行昂贵的检查 DEBUG_ASSERT(is_valid_index(i)); // ... 核心计算逻辑 } }单元测试的速度测试套件运行太慢会阻碍开发流程。保持测试快速的方法隔离慢速测试将与数据库、网络、文件系统交互的测试标记为“慢测试”在本地快速反馈循环中不运行它们只在CI中运行。使用内存数据库或模拟尽可能用内存替代磁盘I/O用模拟对象替代网络调用。并行化测试Google Test支持--gtest_shuffle和--gtest_repeat但更有效的是利用CTest或CI系统的并行测试执行功能。最后我想分享一个最深的体会assert和单元测试本质上都是一种对代码的“不信任”和“验证”思维。assert是对代码内部状态的不信任在运行时自我检查单元测试是对代码外部行为的不信任在开发时反复验证。将这种思维变成编码习惯你会发现自己对程序的理解会更加深刻写出的代码也会更加健壮和自信。开始行动吧从为你下一个要修改的函数添加一个assert和一个测试用例开始。