拓冰建站拓冰建站
首页 / 资讯中心 / 正文

变异测试实战:用元测试检验测试用例的有效性

1. 我在测什么从“我写了用例”到“我的用例真的能抓bug吗”先聊一个我经常在团队里问新人的问题你写完一遍全绿的测试用例你觉得它有效吗大部分人会愣一下。因为平时大家默认“跑完不出错”就是有效的。但干测试时间长了就会撞见一种很尴尬的局面——昨天用例还全绿呢今天开发在代码里埋了个雷把某个条件判断写反了用例照样全绿上线用户一用就炸。那时候你再回头翻那条十几个断言的用例会发现它压根就没走进那个分支或者断言粒度太粗只验了返回结构没验业务结果。所谓“测过了”只是“跑过了”。想彻底搞清楚这一点就得给测试用例本身做一次体检。这个动作业内叫元测试按我的理解它不是叫你写测试用例去测一遍测试代码——那叫单测你那套测试框架的框架代码而是换一个视角去看被测代码被你改了之后你的测试用例是不是真的能把这个错误“捉住”。说白了元测试测的是“你用来测别人的那套网网眼到底够不够密”。这个问题的实操价值在哪儿我举一个特别常见的场景。你有一个登录接口里面写了条校验如果用户名长度小于6就返回错误码1001。你写了三条用例一条正常登录两条分别测用户名过短和密码错误覆盖率看着也有80%CI全绿。看上去很安全。但如果某天开发手滑把判断条件从“小于6”改成了“小于等于6”边界值变了你的三条用例可能全都不受影响依旧全绿。这就是典型的“用例有效但不够锐利”的问题——它只管了某个范围没卡住边界变化。所以后来我开始有意在测试设计阶段引入一种思路先不要问“我怎么写出更多用例”而是问“我现有的这一批用例到底能不能区分出不同版本的代码”。能区分出就是有效的区分不出再多也是自嗨。这个思路用术语包装一下就是你看到的那个高频热词变异测试Mutation Testing。我习惯叫它“给测试用例出考题”——把被测代码里的逻辑故意做点破坏性改动生成一批“带病版本”再去跑你现有的测试用例能把这些带病版本当场抓出来的才叫有效的测试。抓不出来的就是你需要补强的盲区。我在很多文章里见过管这套流程叫元测试的虽然严格学术定义上它叫变异测试但放到工程团队里聊“测试用例的元测试”大家秒懂——就是审查一下你的用例本身有没有战斗力别只是自我感动。这篇文章我就用实际踩坑和跑通的经验讲清楚这套“测试测试用例”的方法到底怎么落地包含了什么原理用什么工具怎么把结果转化为你代码仓库里的质量门禁。2. 测试用例也是代码也有bug和盲区2.1 你写的那一堆assert真的在验业务吗要把元测试做明白得先搞清楚一件事测试用例为什么会有“无效”的时候。最普遍的一种是测试代码本身写错了。不是被测代码出错是你断言逻辑有毛病。比如本来要校验账户余额扣款后是0元结果断言写成“余额小于等于0”那开发若是把扣款逻辑写成直接清零你的用例是绿的开发若把扣款逻辑写成了负数你的用例也绿。它验证不出“恰好等于预期金额”这件事。这就不是被测逻辑对不对的问题是你自己的“测量工具”出了系统性偏差。再一种更隐蔽就是断言“什么也没拦住”。我在接口测试里见过太多用例这么写resp client.post(/api/order, jsonpayload) assert resp.status_code 200状态码200只能说明服务器没抛异常不能说明订单真的创建了、库存真的扣了、消息真的发出去了。你要真想拦截业务回归至少得拿返回体里的order_id去查一次数据库或者断言幂等键、金额合计、状态字段。所以很多测了等于没测的用例问题就出在断言的“颗粒度”上。有了这个背景你再看元测试就通透了。元测试本质上就是故意制造一个“带任务而来”的缺陷版本再把你的测试用例放上去跑一遍。如果测试用例没能发现异常说明它在这一块是瞎的——这正是你测试设计里需要补的盲区。2.2 “能跑通”和“能发现错误”完全不是一回事我举个例子来说明这两者的差异。假设你现在要对下面这个简单的用户密码校验函数写测试def validate_password(password: str) - bool: if len(password) 8: return False if not any(c.isdigit() for c in password): return False if not any(c.isupper() for c in password): return False return True绝大多数测试新人会这么写def test_valid_password(): assert validate_password(Abc12345) is True def test_short_password(): assert validate_password(Abc123) is False def test_no_digit(): assert validate_password(Abcdefgh) is False def test_no_upper(): assert validate_password(abcdef1) is False覆盖率看前面四个分支全是打满的。但你信不信如果这时候有人把校验长度那行的 8改成 8你上面这套用例跑完依然是绿的——因为“Abc123”长度正好是6根本没踩到8这个边界。也就是说这条改错了的代码你根本没测出来。这就是“能跑通”和“能发现错误”的区别。这两者之间隔着一条看不见的线线上叫“有效的测试用例”线下叫“自嗨的测试用例”。元测试的任务就是把这条线给你精确暴露出来。2.3 我给这个思路起的外号拿“变异人”做猎杀演习为了好记也为了在团队里跟开发讲清楚这件事我经常用一个比喻把被测代码想象成一个真人把“变异体”想象成一群长得一模一样但行为奇怪的人。每次改一个地方好比复制出了一个“变异人”。你写的那堆测试用例就相当于安检员。正常代码来的时候安检员放行说明没拦错人变异人来的时候安检员如果不能把他抓出来说明安检标准有漏洞。元测试要干的事就是在可控环境下反复放一批变异人进来看你的安检员到底能拦下几个。拦下来的比例业内叫变异得分Mutation Score。如果变异得分是100%说明这一批你能想到的逻辑改动每一个都被测试用例成功识别。达不到100%那剩下的每一个漏网变异体就代表你测试套件里的一个真实盲区。我去年推动过一个中等规模支付模块接入这套机制第一次跑出来全模块的变异得分只有64%。后来团队往里面补了四十多条用例主要是业务规则边界和状态流转方面的分提升到91%。那之后连续三个迭代周期里测试套件真的成功抓出了两个原本会漏到线上的回归——一个涉及优惠券阈值变更一个涉及提现状态机。从64%到91%的过程就是测试用例从“能跑”到“能打”的过程。3. 变异测试的底层逻辑哪里改得动哪里就可能出bug3.1 一次只改一处程序世界的“单点故障”假设变异测试的原理本身很简单以你当前的被测代码为蓝本系统性地制造一批“微小的改动副本”每一个副本只改一个地方然后拿着整体测试套件去跑这些副本。如果某个副本让测试失败了说明这个改动的错误被用例抓住了那么这个变异体就算被杀死了。反之如果测试依然全绿那这个变异体就是漏网之鱼代表你的测试盲区。这里藏着好几个值得展开的工程细节我一个个说清楚。先说“为什么一次只改一处”。这是变异测试最核心的方法论假设真实世界里的缺陷大部分是单个逻辑点上的错误。比如少了个等号、条件反转、边界偏移、常量被改、异常被吞。一次只引入一个错误可以精确定位是哪条用例覆盖了哪条逻辑如果一次改好几处万一测试失败了你根本分不清是被哪一路变异体抓到的。这个思路跟你做代码评审时一次只指出一个问题、便于作者逐条修改是一个道理。3.2 常见的变异操作不只是改个运算符那么简单每个变异测试工具都维护着一张“变异操作符”表说白了就是把代码里的某一类表达式替换为另一类变体。这张表跟开发者的犯错模式是对齐的。以下是Python生态里最常见的几类变异也都是我实际跑项目时最常撞见的问题区域条件边界变异把改成、把改成。这类对应“边界判断差一”的老梗特别容易隐藏边界逻辑盲区。逻辑运算符变异把and改成or或者反过来。开发在做复杂多条件判断重构时最常在这翻车。算术运算变异把改成-把*改成/。对应金额计算、价格折算这类数值逻辑。删语句变异直接删除某一行赋值语句或一个if分支。对应“写了没生效”之类的死代码问题说白了就是分支压根没被走到。返回值变异把return True改成return False常数替换。对应业务状态判断被写死的逻辑。调用移除变异删除某次函数调用经常用来检测外部依赖调用是否被真的调用到了。每一类操作符背后都对应一种工程师手抖的姿势。工具生成这些变异体后会逐个放到测试流程里去跑然后根据测试结果给每个变异体打上“被杀死”或“存活”的标签。提示你在热词里看到的“接口测试用例”“ai生成测试用例”本质上是在说“被测接口的方法级覆盖”而变异测试关心的是“更小一层语句级和表达式级的行为判别”两件事可以叠加使用但后者比前者更挑剔。3.3 变异体为啥会被分成“被杀”“存活”和“超时”为了便于理解我把一次运行的最终统计口径列一下。变异测试工具跑完后会给出一个汇总通常就是下面这几类状态含义常见原因Killed被杀死测试套件运行结果与基线不同用例成功捕获了逻辑变化Survived存活修改已经生效但所有测试依然通过测试用例存在盲区重点排查Timed Out超时变异体导致程序死循环或性能崩溃多见于被改坏循环条件对测试也是一种守护No Coverage未覆盖变异体所在代码行压根没被执行到测试套件存在大面积覆盖缺口存活变异体是元测试给你的最重要产物它不只是一个统计数字而是具体的代码位置和具体改了哪个操作符。拿到这份清单你可以按图索骥去补测试用例或补断言。这不比盲猜“要不要加两条用例”效率高得多4. 手把手实操把一个登录函数“变异”给你看4.1 挑一个能完整跑通的开源工具链既然要落地就不能光讲理论。目前变异测试的工具有不少Java生态有PITJavaScript/TypeScript生态有StrykerPython生态里比较成熟的是Mutmut和Cosmic Ray。因为平时我Python项目多最常接触的是Mutmut它支持按文件或按函数范围跑变异能集成pytest还支持并行执行跑起来比较省时。下面我用Python项目走一遍完整链路。假设当前项目结构如下project/ ├── app/ │ ├── __init__.py │ ├── auth.py │ └── ... └── tests/ ├── __init__.py ├── conftest.py └── test_auth.py被测代码在app/auth.py测试用例在tests/test_auth.py。先把Mutmut装进你的虚拟环境pip install mutmut如果你的测试框架用的是pytest且测试文件命名符合test_*.py或者*_test.pyMutmut基本开箱即用。它的默认配置就是去找项目根目录下名字以test_开头的目录和文件跑完再把报告写进.mutmut-cache目录。第一次在现有项目上跑我建议你先指定范围只变异你要重点治理的那个文件不要一次性全仓库扫。变异测试是很吃算力的全仓库跑一轮可能要几十分钟甚至几小时但按一个模块跑通常几分钟就出结果。先小范围试点把流程和报告样式看明白再逐步铺开。mutmut run --paths-to-mutate app/auth.py跑完以后看一下结果汇总mutmut results默认情况下Mutmut会把报告按文件分组打印出每个变异体的编号、状态和具体位置。实际输出长这样我脱敏简化过---- app/auth.py ---- 1. 5: 8-10: Replace comparison operator with or 2. 6: 14-16: Replacereturn True with return False 3. 7: 22: Remove call to save_user每条编号边上的冒号之前是被测文件的变异体编号后面会标出该变异体在代码中的行号区间以及做了什么类型的变异。关键就在于看哪些标成了survived。4.2 一个“看似很稳”的登录校验为啥变异得分只有64%光说工具怎么跑没意思还是拿一段真实代码来说实操。假设现在项目里有一段注册接口的密码校验逻辑测试用例看着已经很全了# app/auth.py def validate_registration(username: str, password: str, age: int) - str: if not username or len(username) 4: return INVALID_USERNAME if len(password) 8: return WEAK_PASSWORD if not any(ch.isdigit() for ch in password): return PASSWORD_NEEDS_DIGIT if age 18: return UNDERAGE return OK配套的测试用例我也写了十来条覆盖各个错误分支和成功分支# tests/test_auth.py import pytest from app.auth import validate_registration def test_short_username(): assert validate_registration(ab, Abc12345, 20) INVALID_USERNAME def test_valid_username_but_weak_password(): assert validate_registration(alice, abc123, 20) WEAK_PASSWORD def test_password_has_no_digit(): assert validate_registration(alice, abcdefgh, 20) PASSWORD_NEEDS_DIGIT def test_underage(): assert validate_registration(alice, Abc12345, 17) UNDERAGE def test_valid_registration(): assert validate_registration(alice, Abc12345, 20) OK从代码覆盖率角度看这个测试套件算表现不错——所有if分支都被走了一遍。但把Mutmut跑起来后分数会让你意外。我先说一下最后统计结果全文件大概生成了14个变异体被杀死的只有9个变异得分64%。剩下的存活变异体里有哪些呢我把几个典型的拿出来第一个存活变异体是把len(username) 4改成了len(username) 4。这代表什么说明测试用例里没人验证用户名长度等于4时的边界行为。真实业务中产品经理如果说“用户名最短4位”那4位用户到底能不能注册如果开发按来写表示4位被拒你现有的用例根本测不出来。因为这个边界恰好卡在“你测了不等于4也没测真等于4”的口子上。第二个存活变异体是删除if not any(ch.isdigit() for ch in password):这个分支的返回值。也就是说即使密码里没有任何数字函数依然返回了非PASSWORD_NEEDS_DIGIT的结果——而你的用例没发现。为什么因为你测abcdefgh时它返回了正确的失败码但你并没有再校验如果一个密码同时在长度和数字上都不满足条件优先级该怎么走。开发如果把数字校验分支整个删了你的用例还是绿的。第三个存活变异体更有意思是把if age 18这个判断删掉了。你测了17岁返回UNDERAGE但没测18岁这个边界。如果开发改成age 1818岁用户就被拦住了这又是一个边界上的漏测。这三个存活体全是“测试数量不足”和“测试断言不精准”共同导致的结果。跑一遍元测试的价值就在这里它能用几十分钟的自动计算精准告诉你“你自认为严密的测试套件里有三个明确的盲区分别是这里、这里和这里。”4.3 补测试用例把变异得分从64%拉到100%看到报告之后我一般会把存活的变异体按代码位置逐一定位然后对照业务规则补用例。这个过程并不需要碰被测代码只往测试文件里加东西。针对用户名长度边界我需要补def test_username_length_exactly_four(): assert validate_registration(alice, Abc12345, 20) OK密码校验优先级那两个盲区我需要补def test_weak_password_and_missing_digit_priority(): # 如果开发删除数字校验分支这条用例应失败 assert validate_registration(alice, abcdefgh, 20) PASSWORD_NEEDS_DIGIT def test_short_password_but_has_digit_and_upper(): assert validate_registration(alice, Ab1, 20) WEAK_PASSWORD针对年龄边界我需要补def test_age_exactly_18(): assert validate_registration(alice, Abc12345, 18) OK补完这些重新执行Mutmut。这次结果基本就是100%。你可能觉得我补的用例看起来很简单都是拿边界值去试一下而已。没错变异测试暴露的盲区很多时候就是“边界没补齐”“分支优先级没验证”“异常分支没匹配返回值”——这些正是平时手工设计用例时最容易忽略的地方。注意不要为了追求100%变异得分盲目补用例但100%是一个很好的目标——它是区分测试套件“看起来全绿”和“真正具备行为判别力”的分水岭。4.4 测试的执行顺序也会影响结果用缓存和并行节省时间如果你的项目偏大整个模块或全仓跑变异测试会有明显的时间成本。这里有三个我实测有效的提速手段分享出来给你参考第一个指定范围跑。前面已经说过用--paths-to-mutate只变异你关心的文件这里再强调一遍很多时候我们不需要一次性变异所有代码只变异最近改动过的文件就行。第二个利用缓存。Mutmut默认会缓存已经跑过的变异体和它们的执行结果你改完测试代码后重新运行它只重新跑受影响的那部分不会全部重复执行。这一点在反复调优用例时特别省时间。第三个并行执行。Mutmut支持--workers参数会起多个进程同时跑不同变异体。我实测在四核机器上速度能提升到单线程的三倍左右算得上立竿见影。CI上如果资源充裕可以并行跑本地一次性排查的话优先用前面两个手段就好。mutmut run --paths-to-mutate app/auth.py --workers 45. 从“元测试”结果反推你的测试设计水平5.1 覆盖率不是万能的但变异得分能暴露“假覆盖”很多团队给测试定的质量指标就是代码覆盖率经常卡在80%或者90%。但我在前面已经用例子说明了覆盖率只能说明某一行被执行过不能说明这一行如果出问题你一定能发现。我一直觉得覆盖率是一个必要不充分条件——它太低肯定不行高也不代表安全真正的短板往往藏在高覆盖率的“假覆盖”区域里。变异测试的结果恰好能把这些“假覆盖”揪出来。有一次我负责一个网关服务的重构整个服务行覆盖率在主干上是91%已经很能唬人了。但变异测试跑完存活变异体集中在两个地方一个是入口层的参数校验另一个是超时降级的分支处理。这两个核心逻辑你光看代码覆盖率全是绿的。可一旦参数逻辑被动过测试用例根本没有捕捉能力。这就是“假覆盖”造成的假安全感。后来我把CI里的质量门禁从“覆盖率必须大于X%”提高成“新增代码的变异得分不得低于Y%”。虽然这会让流水线慢一些但对于支付、下单这类核心链路慢这几分钟换来的安全感是非常值的。5.2 用变异得分替代拍脑袋式“补几条用例”测试用例设计方法里有边界值分析、等价类划分、判定表、场景法……但这些方法在实战中能不能被开发人员用好全看个人经验。变异测试给了一个完全不同的角度不用猜哪里容易漏直接把代码的可变性打开给你看什么地方变异体活着你就往哪里补。这像什么像你写作文时先拿到查重报告哪里和别人的句子重合度高就改哪里而不是全文重来一遍赌运气。测试设计也一样变异报告就像一份“盲区雷达图”它告诉你这五个分支没被有效保护这俩边界没人守。有了这个雷达图你再回去用测试用例设计方法针对性地补效率提升不是一点半点。5.3 从一次生成到可持续把元测试嵌入CI的姿势工具跑一次是尝鲜想把它变成日常质量保障的一部分我建议你在CI里按这套思路来做。第一步把变异测试跑在主测试之后只选核心模块或近期改动文件。全量变异放夜间定时任务跑白天CI只跑增量部分。比如git diff出来的新增改动文件用--paths-to-mutate只变异这些文件。第二步在流水线里设置质量门禁变异得分低于阈值的提交直接拦截。阈值怎么定建议先跑一轮全量摸底看现有基线是多少设定一个比基线高一些的“近期可达目标”。例如基线64%你可以先设70%下个迭代再慢慢往上提。不要第一次就设90%然后发现一堆历史债跑不过导致团队天天崩。第三步把存活变异体列表当成缺陷报告来跟踪。CI跑完如果发现有变异体存活最好自动把文件、行号、变异类型贴到工单里指给对应的模块负责人。这比“跑个变异测试看看报告”有用得多因为程序员收到一条“你这个文件第47行的边界条件如果被改坏现有测试发现不了”的消息远比收到一张变异测试概念说明文档要有行动力。有个小技巧GitHub Actions或GitLab CI里可以写一个失败后自动comment到MR的脚本直接在review页面出现“变异存活率超标”的提示。这样测试用例本身的质量问题变成了code review过程中的显性指标比测试组私下补用例要有约束力得多。6. 踩坑实录元测试落地中我遇过的五个坑6.1 一整轮跑了几十分钟结果发现测试环境没secrets变异测试的本质是要在你的测试环境里反复执行测试套件。如果你的测试套件依赖数据库、Redis、第三方接口密钥那么变异体跑起来会特别痛苦。因为每一批变异体都可能触发一次网络调用超时拖慢整体进度。我第一次把变异测试引入一个依赖真实MySQL的模块时跑一轮将近40分钟结果一半的超时来自连不上数据库而不是真正的变异体。后来学乖了给变异测试单独配了一套全内存的测试替身环境把外部依赖全部替换成真实逻辑的轻量实现。这才把40分钟压到6分钟。如果你有依赖容器化的服务也可以把变异测试跑在docker compose起来的隔离环境里保证基础依赖可用且干净。6.2 等价变异体明明跑了测试它是活的但它其实不是bug等价变异体是我踩过最坑的坑。简单说就是某个变异后的版本和原版本在行为上完全等价根本不是缺陷。比如某函数里有一行x x * 1变异体把*改成/它确实变了但如果这个算式后面还有约束保证x恒等于0那不管乘还是除结果一样。这种变异体永远杀不死因为从行为上它跟原代码就是一模一样的。遇到这种变异体千万不要浪费时间硬补测试用例去“杀死”它。正确姿势是给Mutmut加一个豁免名单把那些确认等价的变异体标记掉。工具都支持类似# pragma: no mutate的注释或者通过配置文件跳过指定的变异体编号。在团队协作里最好在code review记录里注明“这个豁免是因为等价变异体理由是什么”避免后期其它人不明就里。6.3 存活的变异体太多全修不现实按优先级逐步啃第一次跑全量时如果项目历史比较久存活变异体数量可能会上百。这种时候最忌讳的就是逼团队一周之内全部清零这会让人产生强烈的抵触情绪。我这个项目的经验是把存活变异体按模块优先级排序先啃核心资金链路、账号安全、订单状态机边缘展示模块的可以暂时豁免。等核心模块得分上去了再逐步扩大范围。质量改进是持久战不是突击战。6.4 变异测试跑得太慢怎么办圈定范围并行增量分析刚才提到了外部依赖问题但即便依赖全没问题一个大模块全部变异一遍也挺费时间的。我现在的实践是分三层平时提交代码只跑“新增改动文件的增量变异”MR合并前跑“目标模块全量变异”每个版本发布前跑一次“核心链路全量变异”。每层的执行频率和范围都不同既不拖慢日常迭代又能在关键节点提供保护。6.5 别把变异得分当成测试质量的唯一指标必须说明变异得分也不是万能的。它反映的是“测试套件对代码逻辑变化的敏感度”但测试设计里还有一种问题它管不了需求理解错了。如果你的测试用例从一开始就基于一个错误的产品预期来写变异测试照样全绿。因为被测代码和错误预期一致的话测试用例始终能通过。所以变异测试不能替代需求评审、用例评审它只是把“测试代码写得好不好”这件事量化了但“测的方向对不对”依然要靠人。7. 工具选型小结几张表帮你选对变异测试框架说完方法论和实操细节把主流工具特点整理出来供你参考。选型核心依据是你们项目语言生态、测试框架兼容性和CI接入成本。工具适用语言测试框架特点MutmutPythonpytest/unittest轻量、默认缓存、支持增量适合中小项目起步Cosmic RayPythonpytest灵活度高但配置门槛略高适合深度定制PITJavaJUnit/TestNG老牌稳定大量插件可接入Gradle/MavenStrykerJavaScript/TypeScriptJest/Mocha等支持框架广报告可视化好适合前端和Node全栈选工具的另外一个硬指标看它是否支持增量变异和结果缓存。前面讲过变异测试执行成本高如果工具不支持缓存在本地开发来回改用例时会特别煎熬。Mutmut的缓存策略在我日常使用中非常顺手这也是我Python项目里首选它的理由之一。注意不论选哪个工具先在一块小型核心代码上跑通闭环写用例→跑变异→看存活→补用例→得分提升再推广到全项目。做POC的阶段别铺太开。8. 从“测试测试用例”到“测试团队的习惯”元测试真正落地后改变得最明显的不是某个数值而是团队对待测试用例的心态。以前大家说“补用例”是觉得“覆盖率可能不够”是一种难以反驳的模糊感觉现在说“补用例”可以直接甩出一份变异存活报告指着一行代码说“这里你改了条件现有用例发现不了必须补”。这种精确感让测试用例的评审和优化变得落地得多。我经历过一次线上漏测最后复盘发现就是典型的边界变异存活开发把缓存过期时间从“大于60秒”调成了“大于等于60秒”测试用例只测了59、61这种值恰好没测60。这个场景我到现在印象都特别深。后来在新项目上跑元测试第一轮就发现同样的边界问题——测试套件对“等于边界”这种情况完全失明。那时候我特别确信给测试用例做元测试这件事不是“锦上添花”而是“保命功夫”。如果你也想在团队里推动这件事我的建议是从一个你熟悉的核心模块开始跑第一轮变异测试把存活列表打出来自己先感受一下那种“我以为我测得很全结果还有这么多盲区”的冲击力再拿着这份报告去找开发聊补用例的事。很多时候数据比苦口婆心管用。我个人现在的习惯是每轮有核心逻辑改动时除了跑常规的单测、接口测试一定会抽几分钟对改动文件跑一次增量变异。成本不高但回报非常实在。毕竟测试用例也是一种代码是代码就应该被测试元测试就是我给测试代码上的那道保险。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门