Assert 断言库发布可一目了然阅读的新版本
来源标题:Asserts You Can Read at a Glance
Assert 推出新版本,主打断言信息可一目了然阅读。该版本让断言输出更直观,便于开发者快速定位问题。具体更新细节和发布时间尚未披露。
以下正文同步自 Hacker News 最新,版权归原站所有,已转换为易读排版。
It’s nice when the value on the right of assert is a literal - doesn’t work when we have a mess on the left though. In this very common case the test starts picking it apart: len(data["users"]), then data["users"][0]["name"], then a regex or a sort. Each line is fine, together they make you rebuild the shape of the data in your head. How can it not? Shaping the expectation Let’s start with a tiny instance of this: 1 2 assert data["status"] == "created" assert re.search(r"^Order #\d+", data["message"]) Two lines, and already no shape to see. A more glancable way would be: 1 2 assert data == {"status": "created", "message": Regex(r"^Order #\d+")} Can this be made to work? Dicts already do their part in Python - they compare their values via ==, so we only need a special Regex class overloading equality: 1 2 3 4 class Regex: ... def eq(self, other): return re.search(self.pattern, other) is not None I first saw this trick in a post by Ihor Kalnytskyi, where it was used just like that, for a single string. But it’s too good to stop at regexes. Tackling the mess Here is a typical test for some API listing users minus real long and messy values: 1 2 3 4 5 6 7 8 9 10 11 12 13 def test_list_users(): data = get_users() assert data["total"] == 2 assert data["next"] is None assert len(data["users"]) == 2 alice, bob = data["users"] assert isinstance(alice["id"], int) assert alice["name"] == "Alice" assert alice["email"].endswith("@example.com") assert sorted(alice["roles"]) == ["admin", "dev"] assert "created" in alice assert bob["name"] == "Bob" assert bob["roles"] == ["dev"] What fields do we check? Do we check same fields for Alice and Bob? Did we forget something? What does response look like? You can tell, but you need to dig in for that. Let’s rewrite it the new way: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 def test_list_users(M): assert get_users() == { "users": [ { "id": M.isa(int), "name": "Alice", "email": M.re(r"@example.com$"), "roles": M.unordered("admin", "dev"), "created": M.any, }, M.dict(name="Bob", roles=["dev"]), ], "total": 2, "next": None, } This is the shape of the response. Two users, a total, no next page. Alice is checked fully, Bob only by name and roles. One glance. I don’t even need to explain to you the M.whatnot() helpers, which I got from assert-matcher, these are quite guessable from their names and use. As a bonus we check that no extra keys allowed for data nor Alice, the messy sample would miss it. Not quite literal The literal above is loose, and on purpose. An exact one would break for the silliest reasons. created is a timestamp, different on every run, but it should be there, hence M.any. id comes from the database, so we only ask for an int. Roles come in unpredictable order, M.unordered() handles that. And Bob? He is there to test filtering, we checked the full fields set on Alice already. So M.dict() with two keys, do not need to repeat all of them. The difference in Alice’s and Bob’s checks is also way more glancable now. And when we do care, we can be as specific as we like. If the test created Alice itself, it knows her id, so we just write it. created can’t be exact, but it should be about now: 1 2 3 4 5 6 7 8 def test_list_users(M): alice = ... assert get_users() == { "users": [{ "id": alice.id, "created": M.approx(datetime.now()), ... }, ...], ...} Breaking Bob Let’s be honest: nobody looks at tests until they fail. So let’s break ours, give Bob one more role, ["dev", "ops"]. With the matchers above, this is what we’ll see: 1 2 3 4 5 6 E AssertionError: assert {'users': [{'... 'next': None} == {'users': [{'... 'next': None} E E Omitting 2 identical items, use -vv to show E Differing items: E {'users': [{'id': 17, 'name': 'Alice', 'email': 'alice@example.com', 'roles': ['dev', 'admin'], ...}, {'id': 23, 'name': 'Bob', 'email': 'bob@example.com', 'roles': ['dev', 'ops'], ...}]} != {'users': [{'id': M.isa(int), 'name': 'Alice', 'email': r'@example.com$', 'roles': M.unordered('admin', 'dev'), ...}, M.dict(name='Bob', roles=['dev'])]} E Use -v to get more diff Pytest found the offending key, users, and stopped there. The rest is two long lines you are supposed to compare by eye. Okay, -vv then: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 E Full diff: E { E 'users': [ E { E - 'id': M.isa(int), E ? ^^^^^^^^^^ E + 'id': 17, E ? ^^ E 'name': 'Alice', E - 'email': r'@example.com$', E ? - - - E + 'email': 'alice@example.com', E ? +++++ E - 'roles': M.unordered('admin', 'dev'), E - 'created': M.any, E + 'roles': [ E + 'dev', E + 'admin', E + ], E + 'created': '2026-10-30T12:01:44', E }, E ... Bob goes below ... Readable, but not at a glance. The actual error is even further down, among Bob’s lines. Since it’s a string diff, it shows plenty false positives: M.isa(int) against 17 is a change and so on. Other limitations that dict’s keys are not sorted, pytest doesn’t dig more than one level and pretty printing may linewrap confusing the diff further. Better call plugin Forturnately it might be fixed with a plugin using the pytest_assertrepr_compare hook. assert-matcher installs one, which turns the above into: 1 2 E AssertionError: assert {'users': ...xt': None} == {'users': ...xt': None} E users[1].roles: Left contains one more item: 'ops' Saves your eyes and your tokens by pinpointing the exact bad path into the nested structure. And it works with plain data with no matchers included too, i.e. if we wrote the whole response exactly and got Bob’s email wrong: 1 2 3 4 5 6 7 8 E AssertionError: assert {'users': ...xt': None} == {'users': ...xt': None} E users[1].email: E 'bob@example.com' == 'bob@example.org' E E - bob@example.org E ? ^^ E + bob@example.com E ? + ^ So just plugin and no matchers might also be a valid choice. Does anyone care anymore? People don’t write tests these days though, few read them. If you still review what the agent wrote the glanceability might be helpful. Talking agents only makes it a topic for another post or even a research, I took a bite at unittest vs pytest vs pytest + matchers here though. Spoiler: matchers kill slightly more mutants, pytest vs unittest is undecisive (need more cases?), costs are the same. So throw this to your agent if you’re sold. P.S. The trick is not new, even stdlib has ANY, a number of other libs went somewhat this way. None of those are as nice and none of them have a proper plugin though.
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。