Giter VIP home page Giter VIP logo

pytest-insta's Introduction

pytest-insta

Build Status PyPI PyPI - Python Version Code style: black

A practical snapshot testing plugin for pytest.

assert snapshot() == "awesome!"

Introduction

Snapshot testing makes it easy to monitor and approve changes by comparing the result of an operation against a previous reference value.

This project borrows from a lot of other implementations to provide a pythonic, batteries included snapshot testing solution. It also tries to feel as native to pytest as possible with its integrated review tool.

Features

  • Expressive and familiar assertion syntax
  • Can format text, binary, hexdump, json and pickle snapshots out-of-the-box
  • Can be extended with custom snapshot formats
  • Interactive review tool for inspecting and approving changes

Credits

  • insta (rust)

    Armin's work was the initial motivation for this project and inspired the reviewing workflow.

  • jest (javascript)

    Jest enabled the mass adoption of snapshot testing throughout the JavaScript ecosystem and now basically stands as the reference when it comes to what snapshot testing is supposed to look like.

Installation

The package can be installed with pip.

$ pip install pytest-insta

Getting Started

The snapshot fixture is a function that returns the current value of a snapshot.

def test_hello_world(snapshot):
    assert snapshot() == "hello"
$ pytest
...
CREATE snapshots/<prefix>__hello_world__0.txt

Running this test will create a new text file in the snapshots directory. The next time pytest runs, the test will load the snapshot and compare it to the actual value.

The return value of the snapshot function can be assigned to a variable and used multiple times.

def test_hello_world(snapshot):
    expected = snapshot()
    assert expected == "hello"
    assert expected.upper() == "HELLO"

By default, each invocation of the snapshot function will generate its own snapshot.

def test_hello_world(snapshot):
    assert snapshot() == "hello"
    assert snapshot() == "world"
$ pytest
...
CREATE snapshots/<prefix>__hello_world__0.txt
CREATE snapshots/<prefix>__hello_world__1.txt

You can also name snapshots explicitly. This makes it possible to load a snapshot multiple times during the same test.

def test_hello_world(snapshot):
    assert snapshot("message.txt") == "hello"
    assert snapshot("message.txt") == "hello"
$ pytest
...
CREATE snapshots/<prefix>__hello_world__message.txt

Snapshot Formats

By default, the snapshot fixture will store snapshots as .txt files. By providing a filename or just a specific file extension, you can create snapshots using various formats supported out-of-the-box.

def test_hello_world(snapshot):
    assert snapshot("json") == {"hello": "world"}
    assert snapshot("expected.json") == {"hello": "world"}
$ pytest
...
CREATE snapshots/<prefix>__hello_world__0.json
CREATE snapshots/<prefix>__hello_world__expected.json

Note that the plugin doesn't diff the snapshot files themselves but actually loads snapshots back into the interpreter and performs comparisons on live python objects. This makes it possible to use snapshot formats that aren't directly human-readable like pure binary files and pickle.

Built-in Formats Extension Supported Types
Plain text .txt str
Binary .bin bytes
Hexdump .hexdump bytes
Json .json Any object serializable by the json module
Pickle .pickle Any object serializable by the pickle module

The built-in formats should get you covered most of the time but you can also really easily implement your own snapshot formats.

from dataclasses import dataclass
from pathlib import Path

from pytest_insta import Fmt

@dataclass
class Point:
    x: int
    y: int

class FmtPoint(Fmt[Point]):
    extension = ".pt"

    def load(self, path: Path) -> Point:
        return Point(*map(int, path.read_text().split()))

    def dump(self, path: Path, value: Point):
        path.write_text(f"{value.x} {value.y}")

def test_hello_world(snapshot):
    assert snapshot("pt") == Point(4, 2)

You can create a custom formatter by inheriting from the Fmt class and defining custom load and dump methods. The extension attribute associates the custom formatter to the specified file extension.

Custom formatters can be defined anywhere in your test suite but it's recommended to keep them in conftest.py if they're meant to be used across multiple files.

Command-line Options

The plugin extends the pytest cli with a new --insta option that accommodates the snapshot-testing workflow. The option can be set to one of the following strategies:

  • update - Record and update differing snapshots
  • update-new - Record and create snapshots that don't already exist
  • update-none - Don't record or update anything
  • record - Record and save differing snapshots to be reviewed later
  • review - Record and save differing snapshots then bring up the review tool
  • review-only - Don't run tests and only bring up the review tool
  • clear - Don't run tests and clear all the snapshots to review

If the option is not specified, the strategy will default to update-none if pytest is running in a CI environment and update-new otherwise. This makes sure that your pipeline properly catches any snapshot you might forget to push while keeping the development experience seamless by automatically creating snapshots as you're writing tests.

The record option is useful if you're in the middle of something and your snapshots keep changing. Differing snapshots won't cause tests to fail and will instead be recorded and saved.

$ pytest --insta record
...
RECORD .pytest_cache/d/insta/<prefix>__hello_world__0.txt

NOTICE 1 snapshot to review

When you're done making changes you can use the review option to bring up the review tool after running your tests. Each differing snapshot will display a diff and let you inspect the new value and the old value in a python repl.

$ pytest --insta review
...
_____________________________ [1/1] _____________________________

old: snapshots/example_hello_world__0.txt
new: .pytest_cache/d/insta/example_hello_world__0.txt

>       assert old == new
E       assert 'hello' == 'world'
E         - world
E         + hello

test_example.py:1: test_hello_world

a: accept, r: reject, s: skip
>>>

Finally, the update option will let you update any differing snapshot according to the current test run, without going through the review tool.

$ pytest --insta update
...
UPDATE snapshots/<prefix>__hello_world__0.txt

It's worth mentioning that the updating, recording and reviewing strategies take into account any filter you might specify with the -k or -m options.

Caveats

The snapshot fixture hijacks equality checks to record changes. This keeps assertions expressive and readable but introduces two caveats that you need to be aware of.

  • Right-sided snapshots ❌

    If an object's __eq__ method doesn't return NotImplemented when its type doesn't match the compared object, the snapshot won't be able to record the updated value if it's placed on the right side of the comparison.

    Explanation

    Strings return NotImplemented when compared to non-string objects so the following test will behave as expected.

    def test_bad(snapshot):
        assert "hello" == snapshot()  # This works

    However, dataclasses return False when compared to objects of different types and won't let the snapshot record any changes when placed on the left-side of the comparison.

    from dataclasses import dataclass
    from pathlib import Path
    
    from pytest_insta import Fmt
    
    @dataclass
    class Point:
        x: int
        y: int
    
    class FmtPoint(Fmt[Point]):
        extension = ".pt"
    
        def load(self, path: Path) -> Point:
            return Point(*map(int, path.read_text().split()))
    
        def dump(self, path: Path, value: Point):
            path.write_text(f"{value.x} {value.y}")
    
    def test_bad(snapshot):
        assert Point(4, 2) == snapshot("pt")  # This doesn't work

    Recommendation ✅

    To avoid confusion and keep things consistent, always put snapshots on the left-side of the comparison.

    def test_good(snapshot):
        assert snapshot() == "hello"
    def test_good(snapshot):
        assert snapshot("pt") == Point(4, 2)
  • Not comparing snapshots ❌

    Snapshots should first be compared to their actual value before being used in other expressions and assertions.

    Explanation

    The comparison records the current value if the snapshot doesn't exist yet. In the following example, the test will fail before the actual comparison and the snapshot will not be generated.

    def test_bad(snapshot):
        expected = snapshot()
        assert expected.upper() == "HELLO"  # This doesn't work
        assert expected == "hello"
    $ pytest
    ...
    >       assert expected.upper() == "HELLO"
    E       AttributeError: 'SnapshotNotfound' object has no attribute 'upper'

    Recommendation ✅

    Always compare the snapshot to its actual value first and only perform additional operations afterwards.

    def test_good(snapshot):
        expected = snapshot()
        assert expected == "hello"
        assert expected.upper() == "HELLO"

Contributing

Contributions are welcome. Make sure to first open an issue discussing the problem or the new feature before creating a pull request. The project uses poetry.

$ poetry install

You can run the tests with poetry run pytest.

$ poetry run pytest

The project must type-check with pyright. If you're using VSCode the pylance extension should report diagnostics automatically. You can also install the type-checker locally with npm install and run it from the command-line.

$ npm run watch
$ npm run check

The code follows the black code style. Import statements are sorted with isort.

$ poetry run isort pytest_insta tests
$ poetry run black pytest_insta tests
$ poetry run black --check pytest_insta tests

License - MIT

pytest-insta's People

Contributors

aberres avatar actions-user avatar dependabot-preview[bot] avatar dependabot[bot] avatar vberlier avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar

pytest-insta's Issues

Partial matching functionality?

Hi @vberlier

First off, what a wonderful library! Thanks for the time and effort you've put into this, it really scratches an itch I've experienced when it comes to testing in the Python world.

I wanted to discuss if there is interest in extending the functionality (which I'd be more than happy to contribute the work towards) to allow for setting "partial" expectations in a snapshot.

Here's a use case to hopefully illustrate that sort of functionality.

I often find myself writing tests like this....

def test_posts_detail(api_client):
    post = PostFactory(title="Amazing deal!", body="Don't miss out", published_at=date(2021, 1, 1))

    response = api_client.get(f"/posts/{post.id}/")

    assert response.json() == {
        "id": str(post.id), 
        "title": "Amazing deal!",
        "body": "Don't miss out", 
        "published_at": "2021-1-1"
    }

It's super useful to be able this against a snapshot. Especially as project go on, and API change over time, the review process really help keep things manageable.
However, there is one snag, most of all the fields are "frozen" but in this case the id is dynamic. I can't control what it will be (i.e depends on how many tests, what operation happened before, etc).

I would love the ability to provide the snapshot with a few selected expectations, i.e something like this....

def test_posts_detail(api_client, snapshot):
    post = PostFactory(title="Amazing deal!", body="Don't miss out", published_at=date(2021, 1, 1))

    response = api_client.get(f"/posts/{post.id}/")

    assert snapshot("json", {"id": str(post.id)}) == response.json()

In this case, the id will always be updated and set at runtime in the test, but the rest of are snapshotted.

Storing snapshots inline

This looks like a really nice project! I was looking for a snapshot testing library to automate tests that compare some JSON responses. One feature I would really like is to store the snapshots in code. For example, something like this:

def test_foo():
    expected_value = snapshot(
        {...}   # some large dict
    )
    actual_value = foo()
    assert expected_value == actual_value

The reason for this is to keep the expected/snapshotted value close to where it is being used, which makes it a lot easier to inspect the expected value, e.g. in code review.

Although I've never used it, it seems that insta (Rust version) supports this, albeit only for strings. I think native Python objects would be ideal, but storing inline as JSON might be easier.

feature request: snapshot name suffix

Thanks for making this! Insta is great.

Suppose I am testing the old and new versions of an API - I may want two flavors of every snapshot. Naively I'd write:

assert snapshot("v2.json") == (await client.get_new_api()).json()
assert snapshot("v1.json") == (await client.get_old_api()).json()

That would work fine as long as I called this only once. But what happens when my test wants to call each API multiple times? If I run with --insta update, I end up with the last response saved. If I run without --insta update, the test obviously fails.

There are two problems here:

  • there's no way to ask for a snapshot to have a name suffix (and the "name" argument is overloaded to mean both "type" and "exact name").
  • the framework does not detect the same snapshot being overwritten multiple times in the same update run.

I'd be happy to send a pull request! Let me know how you feel about this.

Review snapshots on creation too

Firstly, thank you for creating this package. It seems really well made and I have been liking it so far! ❤️

Overview

When I first create a snapshot test with snapshot() and run pytest --insta review, I see this in the output:

========================================================================================== SNAPSHOTS ===========================================================================================
CREATE tests/snapshots/<snapshot name>.json

But I don't get to accept/reject this first snapshot. I then have to manually cat the file and review it.

Why?

IIRC Armin's insta (the rust one), asks you to review the first snapshot too. It would be nice if pytest-insta did the same too.

local and CI snapshot patch are not matching

I'm having trouble getting the snapshot tests to pass in my CI as the snapshot location is different in CI than locally. For example the github runner fails with:

assert <not found:'ast_parsing__vyper_cfgir_home_runner_work_slither_slither_tests_e2e_vyper_parsing_test_data_precedence_vy_foo__0.txt'> 

Locally, it is named ast_parsing__vyper_cfgir_builtins_test_builtins__0.txt and the test passes

"review" chokes on old snapshots in test directory

Steps to reproduce

  • Create a snapshot like snapshot('audit.txt')
  • Run test
  • Rename snapshot like like snapshot('audit.json')
  • Run test
  • Now run test with --insta review

Expected behavior

Things work just fie.
Review works as expected or alternatively I am instructed to do some kind of cleanup.

Actual behavior

As audit.txt is still existing in the cache folder the last pytest call fails with an error like this:

FileNotFoundError: [Errno 2] No such file or directory: 'flaskapp/snapshots/views__cm_views__audit.txt'

Running --insta clear fixes the issue.

For new users, this behavior can be quite unexpected. Especially as one might not even remember anymore that a snapshot with the unexpected name once existed.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.