<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>pytest + ruff 原理动画</title>
<style>
:root{
--bg:#0d1117; --panel:#161b22; --border:#30363d;
--txt:#c9d1d9; --muted:#8b949e;
--green:#3fb950; --red:#f85149; --blue:#58a6ff;
--yellow:#d29922; --purple:#bc8cff; --cyan:#39c5cf;
--mono:'Cascadia Code','Consolas','SFMono-Regular',Menlo,monospace;
}
*{box-sizing:border-box;margin:0;padding:0}
body{
background:radial-gradient(1200px 600px at 50% -10%,#1b2330,#0d1117 60%);
color:var(--txt);font-family:'Segoe UI','Microsoft YaHei',sans-serif;
min-height:100vh;padding:24px;
}
.wrap{max-width:1080px;margin:0 auto}
h1{font-size:24px;text-align:center;margin-bottom:4px}
h1 .lint{color:var(--yellow)} h1 .test{color:var(--green)}
.sub{text-align:center;color:var(--muted);margin-bottom:18px;font-size:14px}
/* 顶部进度 / 控制 */
.controls{display:flex;gap:10px;justify-content:center;align-items:center;
margin-bottom:18px;flex-wrap:wrap}
button{background:var(--panel);color:var(--txt);border:1px solid var(--border);
padding:8px 16px;border-radius:8px;cursor:pointer;font-size:14px;transition:.15s}
button:hover{border-color:var(--blue);color:#fff}
button.primary{background:var(--blue);color:#fff;border-color:var(--blue)}
button:disabled{opacity:.4;cursor:not-allowed}
.dots{display:flex;gap:8px}
.dot{width:10px;height:10px;border-radius:50%;background:var(--border);transition:.2s}
.dot.active{background:var(--blue);transform:scale(1.3)}
.dot.done{background:var(--green)}
.stage{background:var(--panel);border:1px solid var(--border);border-radius:14px;
padding:24px;min-height:440px;position:relative;overflow:hidden}
.scene{display:none;animation:fade .5s ease}
.scene.active{display:block}
@keyframes fade{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
.scene h2{font-size:18px;margin-bottom:14px;display:flex;align-items:center;gap:8px}
.badge{font-size:12px;padding:2px 10px;border-radius:20px;font-weight:600}
.badge.static{background:rgba(210,153,34,.15);color:var(--yellow);border:1px solid var(--yellow)}
.badge.dynamic{background:rgba(63,185,80,.15);color:var(--green);border:1px solid var(--green)}
/* 代码块 */
.code{background:#0d1117;border:1px solid var(--border);border-radius:10px;
padding:14px 16px;font-family:var(--mono);font-size:13.5px;line-height:1.75;
white-space:pre;overflow-x:auto;position:relative}
.code .ln{color:#484f58;user-select:none;display:inline-block;width:22px}
.kw{color:var(--purple)} .fn{color:var(--blue)} .str{color:#a5d6ff}
.cm{color:#8b949e} .num{color:#79c0ff}
/* 扫描线 */
.scanline{position:absolute;left:0;right:0;height:26px;
background:linear-gradient(90deg,transparent,rgba(88,166,255,.25),transparent);
border-top:1px solid var(--blue);border-bottom:1px solid var(--blue);
top:14px;opacity:0;pointer-events:none}
.scanline.run{animation:scan 2.4s linear forwards;opacity:1}
@keyframes scan{from{top:14px}to{top:260px}}
.flow{display:flex;align-items:center;justify-content:center;gap:6px;
flex-wrap:wrap;margin:18px 0}
.node{background:#0d1117;border:1px solid var(--border);border-radius:10px;
padding:12px 14px;text-align:center;min-width:104px;opacity:.3;transition:.4s;font-size:13px}
.node.on{opacity:1;border-color:var(--blue);box-shadow:0 0 14px rgba(88,166,255,.3)}
.node .ic{font-size:24px;display:block;margin-bottom:4px}
.node small{color:var(--muted);display:block;font-size:11px;margin-top:2px}
.arrow{color:var(--muted);font-size:20px;opacity:.3;transition:.4s}
.arrow.on{opacity:1;color:var(--blue)}
.two{display:grid;grid-template-columns:1fr 1fr;gap:16px}
@media(max-width:760px){.two{grid-template-columns:1fr}}
.col{background:#0d1117;border:1px solid var(--border);border-radius:10px;padding:14px}
.col h3{font-size:14px;margin-bottom:10px}
.col.lint h3{color:var(--yellow)} .col.test h3{color:var(--green)}
.report{margin-top:14px;font-family:var(--mono);font-size:13px;min-height:24px}
.report .err{color:var(--red)} .report .ok{color:var(--green)}
.typed{opacity:0;transform:translateX(-6px);transition:.3s}
.typed.show{opacity:1;transform:none}
.pill{display:inline-block;background:#0d1117;border:1px solid var(--border);
border-radius:6px;padding:2px 8px;font-family:var(--mono);font-size:12px;color:var(--cyan)}
.note{margin-top:14px;color:var(--muted);font-size:13px;line-height:1.7;
border-left:3px solid var(--blue);padding-left:12px}
.key{color:var(--yellow);font-weight:600}
/* test 灯 */
.lights{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}
.light{width:30px;height:30px;border-radius:6px;background:var(--border);
display:flex;align-items:center;justify-content:center;font-size:14px;
transition:.3s;color:#0d1117;font-weight:700}
.light.pass{background:var(--green)}
.light.fail{background:var(--red);color:#fff}
/* 文件树 */
.tree,.tree ul{list-style:none;margin:0;padding:0}
.tree ul{margin-left:18px;border-left:1px dashed var(--border);padding-left:6px;
max-height:400px;overflow:hidden;transition:max-height .3s ease}
.tree li{margin:2px 0}
.tree .row{display:inline-flex;align-items:center;gap:6px;padding:4px 8px;border-radius:6px;
cursor:pointer;font-size:13.5px;transition:.12s;user-select:none}
.tree .row:hover{background:rgba(88,166,255,.12)}
.tree .row .ic{font-size:14px}
.tree .tw{display:inline-block;width:12px;color:var(--muted);transition:transform .2s}
.tree .dir.open>.row .tw{transform:rotate(90deg)}
.tree .dir:not(.open)>ul{max-height:0}
.tree .file.sel>.row{background:rgba(88,166,255,.25);color:#fff;
box-shadow:inset 2px 0 0 var(--blue)}
#codeView{display:block;color:var(--txt);font-family:var(--mono);font-size:12.5px;
line-height:1.7;white-space:pre}
.barwrap{background:#0d1117;border:1px solid var(--border);border-radius:8px;
height:30px;overflow:hidden;margin-top:8px}
.bar{height:100%;width:0;background:linear-gradient(90deg,var(--green),var(--cyan));
transition:width .8s ease;display:flex;align-items:center;justify-content:flex-end;
padding-right:8px;color:#0d1117;font-weight:700;font-size:12px}
footer{text-align:center;color:var(--muted);font-size:12px;margin-top:14px}
</style>
</head>
<body>
<div class="wrap">
<h1><span class="lint">ruff (lint)</span> 与 <span class="test">pytest</span> 工作原理动画</h1>
<p class="sub">静态分析「看代码」 vs 动态测试「跑代码」——两者都是外部工具,都不会把自己的逻辑写进你的业务代码</p>
<div class="controls">
<button id="prev">← 上一步</button>
<button id="play" class="primary">▶ 自动播放</button>
<button id="next" class="primary">下一步 →</button>
<div class="dots" id="dots"></div>
</div>
<div class="stage">
<!-- 场景 0: 总览 -->
<section class="scene active" data-scene="0">
<h2>① 两条完全不同的路线</h2>
<div class="two">
<div class="col lint">
<h3>🔍 ruff / lint(静态分析)</h3>
<div class="code"><span class="cm"># 把源码当“文本”读入</span>
<span class="cm"># 解析成 AST(语法树)</span>
<span class="cm"># 用规则匹配,输出报告</span>
<span class="key" style="color:var(--yellow)"># ❌ 不运行你的程序</span></div>
<div class="note">关注:风格、未使用变量、import 排序、潜在 bug 写法……</div>
</div>
<div class="col test">
<h3>🧪 pytest(动态测试)</h3>
<div class="code"><span class="cm"># 收集 test_* 函数</span>
<span class="cm"># 真正调用你的函数</span>
<span class="cm"># 对 assert 求值 → 红/绿</span>
<span class="key" style="color:var(--green)"># ✅ 真的运行你的程序</span></div>
<div class="note">关注:逻辑对不对、结果是否符合预期、异常是否抛出……</div>
</div>
</div>
<div class="note">👉 二者都是<span class="key">独立的工具 + 独立的文件</span>,运行时不会混入被测业务代码。</div>
</section>
<!-- 场景 1: 项目结构(可点击展开) -->
<section class="scene" data-scene="1">
<h2>② 项目结构 <span class="badge static">点击文件夹展开 · 点文件看代码</span></h2>
<div class="two" style="grid-template-columns:0.9fr 1.1fr">
<div class="col">
<h3 style="color:var(--cyan)">📦 pytest_doc/ <small style="color:var(--muted);font-weight:400">(点击节点展开/折叠)</small></h3>
<ul class="tree" id="tree">
<li class="dir open" data-name="src">
<span class="row"><span class="tw">▸</span><span class="ic">📁</span> src/</span>
<ul>
<li class="file" data-file="calculator.py"><span class="row"><span class="tw" style="visibility:hidden">▸</span><span class="ic">🐍</span> calculator.py</span></li>
</ul>
</li>
<li class="dir open" data-name="tests">
<span class="row"><span class="tw">▸</span><span class="ic">📁</span> tests/</span>
<ul>
<li class="file" data-file="test_calculator.py"><span class="row"><span class="tw" style="visibility:hidden">▸</span><span class="ic">🧪</span> test_calculator.py</span></li>
</ul>
</li>
<li class="file" data-file="pytest.ini"><span class="row"><span class="tw" style="visibility:hidden">▸</span><span class="ic">⚙️</span> pytest.ini</span></li>
<li class="file" data-file="pyproject.toml"><span class="row"><span class="tw" style="visibility:hidden">▸</span><span class="ic">🦀</span> pyproject.toml</span></li>
<li class="file" data-file="requirements.txt"><span class="row"><span class="tw" style="visibility:hidden">▸</span><span class="ic">📋</span> requirements.txt</span></li>
<li class="file" data-file="README.md"><span class="row"><span class="tw" style="visibility:hidden">▸</span><span class="ic">📖</span> README.md</span></li>
</ul>
<div class="note" style="font-size:12px">最后一层(文件)就是代码细节,点击右侧查看 →</div>
</div>
<div class="col">
<h3 id="codeTitle" style="color:var(--blue)">👉 点击左侧任意文件查看代码</h3>
<div class="code" style="min-height:300px;max-height:340px;overflow:auto"><code id="codeView">// 选择一个文件…</code></div>
</div>
</div>
</section>
<!-- 场景 2: ruff 原理 -->
<section class="scene" data-scene="2">
<h2>③ ruff 原理 <span class="badge static">静态分析 · 不执行</span></h2>
<div class="flow" id="lintflow">
<div class="node" data-i="0"><span class="ic">📄</span>源码文本<small>calculator.py</small></div>
<span class="arrow" data-i="0">→</span>
<div class="node" data-i="1"><span class="ic">🔡</span>词法分析<small>tokens</small></div>
<span class="arrow" data-i="1">→</span>
<div class="node" data-i="2"><span class="ic">🌳</span>语法树<small>AST</small></div>
<span class="arrow" data-i="2">→</span>
<div class="node" data-i="3"><span class="ic">📐</span>规则匹配<small>E/F/I/B/UP</small></div>
<span class="arrow" data-i="3">→</span>
<div class="node" data-i="4"><span class="ic">📋</span>报告<small>不改逻辑</small></div>
</div>
<div class="code" style="margin-top:6px">
<div class="scanline" id="scan1"></div><span class="ln">10</span><span class="kw">import</span> pytest
<span class="ln">11</span><span class="kw">from</span> src.calculator <span class="kw">import</span> add, subtract, multiply, divide, is_even
<span class="ln">12</span>
<span class="ln">13</span><span class="kw">def</span> <span class="fn">test_add</span>():
<span class="ln">14</span> <span class="kw">assert</span> <span class="fn">add</span>(<span class="num">2</span>, <span class="num">3</span>) == <span class="num">5</span>
<span class="ln">15</span> unused = <span class="num">42</span> <span class="cm"># 没用到的变量</span></div>
<div class="report" id="lintreport"></div>
<div class="note">ruff 全程只是“阅读 + 比对规则”,<span class="key">从不执行</span> <span class="pill">add()</span>。所以它不会把检查逻辑写进代码。</div>
</section>
<!-- 场景 3: ruff --fix -->
<section class="scene" data-scene="3">
<h2>④ <span class="pill">ruff check --fix</span> 会改写「你的源码格式」</h2>
<div class="two">
<div class="col">
<h3 style="color:var(--red)">修复前</h3>
<div class="code"><span class="kw">from</span> src.calculator <span class="kw">import</span> add, subtract, multiply, divide, is_even</div>
<div class="report"><span class="err">I001 Import block is un-sorted</span></div>
</div>
<div class="col">
<h3 style="color:var(--green)">修复后(自动排序)</h3>
<div class="code"><span class="kw">from</span> src.calculator <span class="kw">import</span> <span id="sorted"></span></div>
<div class="report"><span class="ok">All checks passed! ✅</span></div>
</div>
</div>
<div class="note">
⚠️ 注意区别:它修改的是<span class="key">你自己的代码格式</span>(排序/空格/引号),
而<span class="key">不是</span>“把 lint 规则塞进你的代码”。两回事。
<br>同理 <span class="pill">ruff format .</span> 只负责统一排版,不改变程序行为。
</div>
</section>
<!-- 场景 4: pytest 原理 -->
<section class="scene" data-scene="4">
<h2>⑤ pytest 原理 <span class="badge dynamic">动态执行 · 真跑代码</span></h2>
<div class="flow" id="testflow">
<div class="node" data-i="0"><span class="ic">🔎</span>收集<small>test_*</small></div>
<span class="arrow" data-i="0">→</span>
<div class="node" data-i="1"><span class="ic">⚙️</span>调用函数<small>真的运行</small></div>
<span class="arrow" data-i="1">→</span>
<div class="node" data-i="2"><span class="ic">⚖️</span>assert 求值<small>比较结果</small></div>
<span class="arrow" data-i="2">→</span>
<div class="node" data-i="3"><span class="ic">🚦</span>红 / 绿<small>pass/fail</small></div>
</div>
<div class="code" style="margin-top:6px"><span class="kw">def</span> <span class="fn">test_divide_by_zero</span>():
<span class="kw">with</span> pytest.<span class="fn">raises</span>(ValueError):
<span class="fn">divide</span>(<span class="num">1</span>, <span class="num">0</span>) <span class="cm"># ← pytest 真的调用它,看是否抛异常</span></div>
<div class="lights" id="lights"></div>
<div class="report" id="testreport"></div>
<div class="note">pytest <span class="key">真实执行</span>了 <span class="pill">divide(1,0)</span>,捕获到 <span class="pill">ValueError</span> → 这条用例变绿。</div>
</section>
<!-- 场景 5: 完整流程 -->
<section class="scene" data-scene="5">
<h2>⑥ 完整流程(开发 / CI 质量门禁)</h2>
<div class="flow" id="ciflow">
<div class="node" data-i="0"><span class="ic">✍️</span>写代码</div>
<span class="arrow" data-i="0">→</span>
<div class="node" data-i="1"><span class="ic">🔧</span>ruff --fix<small>修 lint</small></div>
<span class="arrow" data-i="1">→</span>
<div class="node" data-i="2"><span class="ic">🎨</span>ruff format<small>统一排版</small></div>
<span class="arrow" data-i="2">→</span>
<div class="node" data-i="3"><span class="ic">🧪</span>pytest<small>跑测试</small></div>
<span class="arrow" data-i="3">→</span>
<div class="node" data-i="4"><span class="ic">🚀</span>合并/部署</div>
</div>
<div class="code" style="margin-top:6px"><span class="cm"># 一行搞定质量门禁:任意一步失败则整体失败</span>
ruff check . <span class="kw">&&</span> ruff format --check . <span class="kw">&&</span> pytest</div>
<div class="barwrap"><div class="bar" id="cibar">0%</div></div>
<div class="report" id="cireport"></div>
<div class="note">
<span class="key">lint 看代码、pytest 跑代码</span>,二者互补:
前者保证“写得规范、没有低级错误”,后者保证“逻辑真的对”。
都不会污染你的业务代码。
</div>
</section>
</div>
<footer>用 ← → 或按钮切换步骤 · 可点击「自动播放」· 本文件可离线打开</footer>
</div>
<script>
const scenes=[...document.querySelectorAll('.scene')];
const dotsBox=document.getElementById('dots');
let cur=0, autoTimer=null;
scenes.forEach((_,i)=>{
const d=document.createElement('div');d.className='dot'+(i===0?' active':'');
d.onclick=()=>go(i);dotsBox.appendChild(d);
});
const dots=[...document.querySelectorAll('.dot')];
function go(n){
if(n<0||n>=scenes.length)return;
scenes[cur].classList.remove('active');
cur=n;
scenes[cur].classList.add('active');
dots.forEach((d,i)=>{
d.classList.toggle('active',i===cur);
d.classList.toggle('done',i<cur);
});
document.getElementById('prev').disabled=(cur===0);
document.getElementById('next').disabled=(cur===scenes.length-1);
runScene(cur);
}
document.getElementById('next').onclick=()=>{stopAuto();go(cur+1)};
document.getElementById('prev').onclick=()=>{stopAuto();go(cur-1)};
document.addEventListener('keydown',e=>{
if(e.key==='ArrowRight'){stopAuto();go(cur+1)}
if(e.key==='ArrowLeft'){stopAuto();go(cur-1)}
});
const playBtn=document.getElementById('play');
playBtn.onclick=()=>{ autoTimer?stopAuto():startAuto(); };
function startAuto(){
playBtn.textContent='⏸ 暂停';
autoTimer=setInterval(()=>{
if(cur>=scenes.length-1){go(0);} else {go(cur+1);}
},3800);
}
function stopAuto(){ if(autoTimer){clearInterval(autoTimer);autoTimer=null;playBtn.textContent='▶ 自动播放';} }
function seqOn(nodes,arrows,delay=550){
nodes.forEach(n=>n.classList.remove('on'));
arrows.forEach(a=>a.classList.remove('on'));
let i=0;
const step=()=>{
if(i<nodes.length){nodes[i].classList.add('on'); if(arrows[i])arrows[i].classList.add('on'); i++; setTimeout(step,delay);}
};
step();
}
function runScene(n){
if(n===2){
const nodes=[...document.querySelectorAll('#lintflow .node')];
const arrows=[...document.querySelectorAll('#lintflow .arrow')];
seqOn(nodes,arrows);
const scan=document.getElementById('scan1');
scan.classList.remove('run');void scan.offsetWidth;
setTimeout(()=>scan.classList.add('run'),900);
const rep=document.getElementById('lintreport');
rep.innerHTML='';
setTimeout(()=>{
rep.innerHTML='<span class="typed err">I001 import 未排序 · F841 unused 变量未使用</span>';
requestAnimationFrame(()=>rep.querySelector('.typed').classList.add('show'));
},3400);
}
if(n===3){
const s=document.getElementById('sorted');s.textContent='';
const full='add, divide, is_even, multiply, subtract';
let i=0;
(function type(){ if(i<=full.length){s.textContent=full.slice(0,i++);setTimeout(type,40);} })();
}
if(n===4){
const nodes=[...document.querySelectorAll('#testflow .node')];
const arrows=[...document.querySelectorAll('#testflow .arrow')];
seqOn(nodes,arrows,600);
const box=document.getElementById('lights');box.innerHTML='';
const total=11;
for(let k=0;k<total;k++){const l=document.createElement('div');l.className='light';box.appendChild(l);}
const lights=[...box.children];
const rep=document.getElementById('testreport');rep.innerHTML='';
setTimeout(()=>{
lights.forEach((l,k)=>setTimeout(()=>{l.classList.add('pass');l.textContent='✓';},200*k));
setTimeout(()=>{
rep.innerHTML='<span class="typed ok">=== 11 passed in 0.04s === ✅</span>';
requestAnimationFrame(()=>rep.querySelector('.typed').classList.add('show'));
},200*total+300);
},1600);
}
if(n===5){
const nodes=[...document.querySelectorAll('#ciflow .node')];
const arrows=[...document.querySelectorAll('#ciflow .arrow')];
seqOn(nodes,arrows,500);
const bar=document.getElementById('cibar');bar.style.width='0';bar.textContent='0%';
const rep=document.getElementById('cireport');rep.innerHTML='';
const steps=[['ruff check',33],['ruff format',66],['pytest',100]];
steps.forEach((st,k)=>setTimeout(()=>{bar.style.width=st[1]+'%';bar.textContent=st[0]+' ✓';},900+k*900));
setTimeout(()=>{
rep.innerHTML='<span class="typed ok">质量门禁全部通过 → 可以合并 🚀</span>';
requestAnimationFrame(()=>rep.querySelector('.typed').classList.add('show'));
},900+steps.length*900);
}
}
/* ===== 文件树交互 ===== */
const FILES = {
"calculator.py":
`"""一个简单的计算器模块,用于演示 pytest 的基本用法。"""
def add(a, b):
"""返回两个数之和。"""
return a + b
def subtract(a, b):
"""返回 a 减 b 的结果。"""
return a - b
def multiply(a, b):
"""返回两个数之积。"""
return a * b
def divide(a, b):
"""除数为 0 时抛出 ValueError。"""
if b == 0:
raise ValueError("除数不能为 0")
return a / b
def is_even(n):
"""判断一个整数是否为偶数。"""
return n % 2 == 0`,
"test_calculator.py":
`import pytest
from src.calculator import add, divide, is_even, multiply, subtract
# 1) 基本断言
def test_add():
assert add(2, 3) == 5
# 2) 异常断言
def test_divide_by_zero():
with pytest.raises(ValueError, match="除数不能为 0"):
divide(1, 0)
# 3) 参数化:一份逻辑跑多组数据
@pytest.mark.parametrize("number, expected", [
(2, True), (3, False), (0, True), (-4, True), (7, False),
])
def test_is_even(number, expected):
assert is_even(number) == expected
# 4) fixture:提供共享数据
@pytest.fixture
def calc_data():
return {"a": 8, "b": 2}
def test_with_fixture(calc_data):
assert add(calc_data["a"], calc_data["b"]) == 10
assert divide(calc_data["a"], calc_data["b"]) == 4`,
"pytest.ini":
`[pytest]
pythonpath = . ; 把根目录加入导入路径
testpaths = tests ; 只在 tests 下收集用例
python_files = test_*.py
python_functions = test_*
addopts = -v -ra ; 默认参数:详细输出 + 结果摘要`,
"pyproject.toml":
`[tool.ruff]
target-version = "py39"
line-length = 100
src = ["src", "tests"]
[tool.ruff.lint]
# E/W=PEP8 F=pyflakes I=isort B=bugbear UP=pyupgrade
select = ["E", "W", "F", "I", "B", "UP"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"`,
"requirements.txt":
`pytest>=8.0
ruff>=0.8`,
"README.md":
`# pytest + ruff 快速上手示例
运行测试:
python -m pytest 运行全部
pytest -v 详细输出
pytest -k "divide" 按关键字筛选
pytest -x 首个失败即停止
代码检查(ruff):
ruff check . lint 检查
ruff check --fix . 自动修复
ruff format . 自动格式化
一键质量门禁(CI 常用):
ruff check . && ruff format --check . && pytest`
};
const codeView=document.getElementById('codeView');
const codeTitle=document.getElementById('codeTitle');
const tree=document.getElementById('tree');
tree.addEventListener('click',e=>{
const row=e.target.closest('.row'); if(!row)return;
const li=row.parentElement;
if(li.classList.contains('dir')){
li.classList.toggle('open');
return;
}
if(li.classList.contains('file')){
tree.querySelectorAll('.file.sel').forEach(f=>f.classList.remove('sel'));
li.classList.add('sel');
const name=li.dataset.file;
codeTitle.textContent='📄 '+name;
codeView.textContent=FILES[name]||'(无内容)';
}
});
go(0);
</script>
</body>
</html>