news 2026/9/16 15:48:24

用 RSpec 践行 TDD:以 Ruby 构建命令行版 Connect Four 的完整实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用 RSpec 践行 TDD:以 Ruby 构建命令行版 Connect Four 的完整实战

用 RSpec 践行 TDD:以 Ruby 构建命令行版 Connect Four 的完整实战

【免费下载链接】curriculumThe open curriculum for learning web development项目地址: https://gitcode.com/GitHub_Trending/cu/curriculum

TDD(测试驱动开发)的核心是把"先写代码、后补测试"的习惯反转过来:先写一个必然失败的测试,再写恰好让测试通过的代码,最后重构。本篇指南以当前开源课程仓库中的 project_connect_four.md 为骨架,完整还原该课程的训练路径——先为旧的 Caesar Cipher、Tic Tac Toe 项目补写 RSpec 测试,再以 TDD 流程从零构建命令行版 Connect Four。读完本文,你将掌握 RSpec 的describe/it/expect语法、常用匹配器、mock/double 隔离技巧、RuboCop RSpec 的接入方法,以及"红-绿-重构"循环的完整落地方式。

为什么"为旧项目补写测试"是最佳热身

原文档开篇就点明了一个反直觉但极其有效的学习方法:想要快速熟悉一个新项目并开始为其贡献,最好的方式就是先给它写测试。这与你入职后接手陌生代码库的体验完全一致——写测试迫使你逐行阅读源码、厘清每个公开方法的输入输出、搞清楚类与类之间的依赖关系,远比"通读一遍"更有效。

原文档还给出一个非常现实的提醒:测试代码的行数往往是项目业务代码的两倍。这不是夸张。一个完整的测试套件通常要为每个方法覆盖正常路径、边界情况和异常路径,再加上大量describe/context分组与let变量,代码量自然水涨船高。所以写测试时感到"代码越写越多"是正常现象,恰恰说明你的覆盖在走向完备。

在开始任何测试之前,请先确认你已经掌握了 RSpec 的基础语法。本仓库的 RSpec 基础入门 与 RSpec 代码共享机制 两课,完整讲解了describe(示例组)、it(单个示例)、expect(...).to(期望)、匹配器、Arrange-Act-Assert 三段式、before/after钩子、letsubject的用法;而 测试驱动开发 一课则系统阐述了 TDD 的"红-绿-重构"循环及其价值。如果这些概念还不牢固,建议先通读这三篇再动手做本项目的练习。

热身任务一:在 ruby_testing 仓库中完成 spec 练习

原文档给出的第一个任务,是克隆官方配套的ruby testing 练习仓库TheOdinProject/ruby_testing),并完成其中spec文件夹内的课程练习。

这类练习仓库的典型结构是:

ruby_testing |__lib # 待测试的业务代码 |__spec # 课程配套的测试任务(部分留空,等你补全) |__.rspec

练习的设计思路通常是:仓库里已经给出部分测试骨架和对应实现,你需要模仿已有测试的风格,为尚未覆盖的方法补写测试。这是成本最低的 RSpec 上手方式——因为答案是否正确可以立刻通过rspec命令验证,而且你能在"读别人写好的测试"与"自己写测试"之间来回切换,快速建立语法手感。

原文档同时建议观看 Sandi Metz 关于 Ruby 测试的讲解视频。Sandi Metz 的核心主张与本文主题高度一致:测试关注的是消息(messages)与行为(behaviors),而非内部实现细节——这直接呼应了后面 Tic Tac Toe 任务中"用 mock 隔离方法"的要求。

热身任务二:为 Caesar Cipher 补写测试

在 Caesar Cipher 项目 中,你已经实现过一个凯撒密码加密函数。现在回头为它写测试,原文档给出的建议是:"不应该超过半打(约 6 个)测试"。凯撒密码的输入空间虽然大,但测试只需覆盖几类典型用例:

# spec/caesar_cipher_spec.rb(示例) require "./lib/caesar_cipher" RSpec.describe "Caesar Cipher" do it "shifts letters by the given factor" do expect(caesar_cipher("hello", 3)).to eql("khoor") end it "wraps around from z to a" do expect(caesar_cipher("z", 1)).to eql("a") end it "preserves case" do expect(caesar_cipher("Hello", 1)).to eql("Ifmmp") end it "keeps non-alphabetic characters unchanged" do expect(caesar_cipher("a!b", 1)).to eql("b!c") end it "handles negative shifts" do expect(caesar_cipher("b", -1)).to eql("a") end it "returns the original string when shift is a multiple of 26" do expect(caesar_cipher("hello", 26)).to eql("hello") end end

原文档特别强调:做这个练习时要使用完整的 git 工作流——像 Revisiting Rock Paper Scissors 一课中学到的那样,为新增测试与实现创建独立的功能分支(feature branch),避免直接在主线分支上修改代码。这既是版本控制练习,也是"在安全环境里大胆重构"的前提。

让 RuboCop RSpec 开始工作

在这个任务中,原文档建议你正式引入RuboCop RSpec扩展。此前的 Linting and RuboCop 一课已经介绍过 RuboCop 本身——它通过 Style、Lint、Metrics 等部门检查 Ruby 代码质量;而rubocop-rspec是官方扩展,专门针对 RSpec 测试代码的常见陷阱给出告警,例如:

  • 单个it块中出现多个expect(应拆分成独立示例);
  • 使用实例变量(@user)而非let/subject共享测试数据;
  • 过度使用should旧语法等。

接入流程与普通 RuboCop 扩展一致:先在Gemfile中加入gem 'rubocop-rspec', require: false,然后在项目根目录的.rubocop.yml中声明插件:

plugins: - rubocop-rspec

之后运行bundle exec rubocop即可同时检查业务代码与 spec 代码。原文档的立场非常明确:从此刻起,所有涉及 RSpec 的项目都建议安装并使用 RuboCop RSpec——让机器替你记住测试规范,把注意力留给测试本身的设计。

热身任务三:为 Tic Tac Toe 补写测试

前一个 OOP 项目 是命令行版 Tic Tac Toe(井字棋)。为它写测试比 Caesar Cipher 复杂得多:测试不再是"给几个输入、断言返回值"那么简单,因为胜负判定依赖棋盘整体状态。原文档给出了三步测试策略:

1. 先测胜负判定(最关键的条件分支)

#game_over(或你实现中对应的方法)为例,测试目标非常明确:当棋盘上出现胜利局面时,方法必须返回真。例如棋盘第一行全部为 X 时,#game_over应触发:

RSpec.describe Game do describe "#game_over" do it "returns true when the top row is all X" do board = [ %w[X X X], %w[O _ _], %w[_ _ _] ] game = Game.new(board) expect(game.game_over?).to be(true) end it "returns true when a column is all O" do board = [ %w[X O _], %w[X O _], %w[_ O _] ] game = Game.new(board) expect(game.game_over?).to be(true) end it "returns false when no one has won yet" do board = [ %w[X _ _], %w[_ O _], %w[_ _ _] ] game = Game.new(board) expect(game.game_over?).to be(false) end end end

注意这里使用了be(true)/be(false)匹配器——对于返回布尔值的谓词方法(以?结尾),be匹配器比eq更贴切、可读性更好。行、列、对角线三类胜利路径都要覆盖,这正是"玩家该赢时必须赢"的语义。

2. 逐一测试关键方法并覆盖边界情况

除了#game_over,还应对棋盘展示、落子合法性(如占用格子不可再落子)、平局判定等关键方法逐一写测试,并刻意构造边界输入。网格类游戏最容易出 bug 的地方恰恰是边界:棋盘已满但无人获胜、最后一格落子恰好形成胜利、在非空位置落子等。

3. 用 mock/double 隔离方法依赖

原文档要求的最后一步是"使用 mocks/doubles 隔离方法,确保它们返回正确的输出"。这是 RSpec 中极具价值的一环:当被测方法与另一个复杂对象协作时,你并不想为每次测试都真实构造那个对象(尤其当它依赖随机数、用户输入或 IO 时)。这时可以注入一个 double:

RSpec.describe Game do describe "#switch_player" do it "passes the current board to the next player" do fake_player = double("Player") board = instance_double("Board", to_display: "...") game = Game.new(board: board, players: [fake_player]) # 验证 game 确实向玩家发送了消息 expect(fake_player).to receive(:take_turn).with(board) game.switch_player end end end

double创建的是一个"替身"对象,你只关心被测方法是否以正确的参数向它发送了正确的消息(message),而不关心替身内部如何实现。这正是 Sandi Metz 所强调的面向行为而非实现的测试哲学。RSpec 提供了从double(完全替身)到instance_double(校验方法签名与真实类一致的严格替身)的一整套工具,具体写法可以在本仓库的 RSpec 基础入门 中结合示例研习。

主项目:以 TDD 构建命令行版 Connect Four

完成上述热身之后,正式项目登场:用 TDD 方式构建命令行版 Connect Four(四子棋)

游戏规则与设计要点

Connect Four 的规则足够简单:两名玩家轮流把棋子从顶部投放到一个竖直的网格(cage)中,棋子落到底部或已有棋子之上;谁先在水平、垂直或对角线方向连成 4 颗棋子,谁就获胜。你之前已经构建过多个命令行游戏(井字棋、石头剪刀布等),纯 Ruby 实现这部分对你不构成挑战——真正的新挑战是流程,而不是逻辑

原文档给了两个锦上添花的建议:

  • 想让棋子在终端里更好看,可以查阅Unicode 杂项符号表,用🔴等特殊字符代替普通的X/O
  • 整个开发过程必须全程 TDD

TDD 循环:一次只走一小步

原文档把 TDD 的节奏描述得非常具体,每一步的思维过程大致是:

  1. 思考需要发生什么——例如"玩家把棋子放进第 3 列,棋子应落到该列最低的空位";
  2. 写一个(必然失败的)测试——此时甚至还没有对应方法,运行rspec会得到 NameError 或失败;
  3. 写恰好能让测试通过的代码——不多写一行;
  4. 思考能否重构——在不改变行为的前提下改善代码结构,然后重新跑测试确认仍然是绿的。

这段循环对应 RSpec 输出中的"红 → 绿 → 重构":

# 红:失败的测试 F Failures: 1) Board#drop_token drops a token to the lowest empty slot in the column Failure/Error: expect(board.drop_token(0)).to eql(5) expected: 5 got: nil

原文档特别提醒初学者不要急于求成:一个方法往往需要两个测试才能让它真正"有用"。例如#drop_token的第一个测试让它能返回行索引,第二个测试则验证连续投两次时第二次落在上一行——这看起来像是"过度设计",但正是 TDD 的意义所在:每个行为都有测试兜底,后续重构才敢放手去做。

# 建议的测试设计(示例) RSpec.describe Board do describe "#drop_token" do it "drops the first token to the bottom row" do board = Board.new expect(board.drop_token(0)).to eql(5) # 6 行网格的最底行 end it "drops the second token above the first one" do board = Board.new board.drop_token(0) expect(board.drop_token(0)).to eql(4) end end describe "#winning_combination?" do it "detects four tokens in a horizontal row" do # 构造第 5 行 0-3 列均为同色棋子的棋盘 board = Board.new # ... expect(board.winning_combination?(player: "X")).to be(true) end it "detects four tokens in a vertical column" do # 构造第 3 列 2-5 行均为同色棋子的棋盘 # ... expect(board.winning_combination?(player: "X")).to be(true) end it "detects four tokens along a diagonal" do # 构造左上到右下方向的连续四子 # ... expect(board.winning_combination?(player: "X")).to be(true) end it "returns false when there is no winner" do # 空棋盘或未形成四连的局面 # ... expect(board.winning_combination?(player: "X")).to be(false) end end end

建议的类拆分与测试顺序

参考你在 井字棋项目 中已经养成的 OOP 习惯,Connect Four 至少可以拆出三类,每类对应一组独立的 spec:

  • Board:管理 6×7 网格,提供#drop_token#winning_combination?#full?#display等方法——这一层与 IO 无关,最容易用纯测试覆盖;
  • Player:持有名称与棋子符号,行为极简;
  • Game:负责主循环(轮流落子、判断胜负、处理平局、渲染棋盘)——这一层与命令行交互耦合,测试时多用 double 替身隔离输入输出。

推荐的测试顺序恰好就是 TDD 的自然顺序:先 Board(纯逻辑,测试最好写),再 Player,最后 Game 的流程编排。每写完一个失败测试就实现对应方法,保持测试套件始终处于绿色状态。

真正在学的不是 Ruby,而是 RSpec

原文档有一段非常诚恳的话值得反复咀嚼:做这个项目时你会花大量时间 Google"如何测试某个特定功能",这完全正常——因为你真正在学的是 RSpec,而不是 Ruby。它需要一些时间去适应。常见的困惑包括:

  • "测试应该断言什么"——断言行为(返回值、发送的消息),不断言实现细节;
  • "一个方法该拆成几个测试"——一个行为一个测试,边界情况单独成例;
  • "double 该用多严格"——测试内部协作用double,需要校验签名用instance_double

当你想清楚"我要让这件事发生,那我该怎么测试它"时,你就已经掌握了 TDD 的精髓:测试在驱动设计,而不是在事后验证

附:RSpec 快速参考卡

为了让项目推进更顺畅,这里把本课程此前学到的核心语法浓缩成一张参考卡(完整讲解见 RSpec 基础入门):

语法作用
RSpec.describe Class do ... end定义顶层示例组,通常以被测类为参数
describe "#method"/describe ".class_method"嵌套示例组,#表示实例方法,.表示类方法
context "when ..."按状态/条件分组,是describe的别名,用于语义区分
it "should ..." do ... end定义单个测试示例
expect(actual).to eq(expected)相等匹配器(==
expect(actual).to eql(expected)严格相等匹配器(eql?
expect(actual).to be(true)/be(false)布尔匹配器,适合谓词方法
expect(collection).to include(x)包含匹配器,适合集合断言
expect(...).not_to ...反向断言
let(:name) { ... }/subject(:obj) { ... }惰性共享变量/被测对象
before { ... }/after { ... }每个示例执行前后的钩子
double("name")/instance_double("Class")替身对象,用于隔离协作对象
expect(dbl).to receive(:msg).with(args)消息期望,验证方法向替身发送了正确消息

另外记得三个工程化习惯:测试文件统一放在spec/目录并以_spec.rb结尾;每个测试尽量只保留一个expect断言;在.rspec中开启--format documentation--order rand,让测试输出可读且随机排序执行,从而暴露测试间的状态泄漏。

附加资源

原文档为深入读者提供了两份补充材料:

  • 关于RSpec Mock Object(替身对象)的实战示例——当你需要验证"方法是否以正确参数调用了协作对象"时,这是最直接的学习素材,可与本文 Tic Tac Toe 部分的 double 示例互相印证;
  • 关于RSpec 编写 Spec的入门文章——适合对describe/it/expect结构还想再巩固一遍的读者。

两份材料都指向同一目标:把测试写得像文档一样清晰、像代码一样可靠。回到开篇那句话——测试代码最终会占据整个项目代码量的很大比重,而你现在做的每一组it块,都是在为未来接手更大代码库、以及为他人理解你的代码铺路。保持红绿循环,保持小步前进,Connect Four 值得你为它写满一整页测试。

【免费下载链接】curriculumThe open curriculum for learning web development项目地址: https://gitcode.com/GitHub_Trending/cu/curriculum

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 15:46:43

STM32模拟SPI驱动TLC3578实现8路±10V高精度采集

简介:针对STM32开发者,这份资源围绕TLC3578模数转换芯片,提供基于模拟SPI的驱动方案,解决8通道10V信号采集与单片机通信问题。资源共152个文件,整体4.35MB,包含H/C源文件、Keil工程文件、PDF说明文档、编译…

作者头像 李华
网站建设 2026/9/16 15:46:36

MATLAB中SA-PSO混合优化算法原理与神经网络训练实战

简介:本资源是一套面向MATLAB初学者与优化算法实践者的融合型算法实现代码包,聚焦于智能优化领域中模拟退火(SA)与粒子群(PSO)算法的协同改进思路,适用于高校课程设计、毕业设计及科研原型验证等…

作者头像 李华
网站建设 2026/9/16 15:46:09

用 mcp-agent 构建带日志与进度通知能力的 MCP Server 实战指南

用 mcp-agent 构建带日志与进度通知能力的 MCP Server 实战指南 【免费下载链接】mcp-agent Build effective agents using Model Context Protocol and simple workflow patterns 项目地址: https://gitcode.com/GitHub_Trending/mc/mcp-agent 导读 本篇基于 mcp-agen…

作者头像 李华
网站建设 2026/9/16 15:44:59

AI短漫剧工业化生产全链路方案解析

1. 项目概述:这不是“用AI画几张图”,而是一整套工业化短漫剧流水线“腾讯云AIGC全链路方案:降低AI短漫剧制作成本并提升产能”——这个标题里藏着三个被很多人忽略的关键词:全链路、工业化、短漫剧。不是单点工具,不是…

作者头像 李华