Skip to content

Commit 0a9b2d3

Browse files
ehmatthesEric Matthes
and
Eric Matthes
authored
Implement end-to-end testing (#92)
* Implements end-to-end test suite using pytest, git-dummy, and adds testing docs Signed-off-by: Eric Matthes <eric@Erics-Mac-Studio.local> --------- Signed-off-by: Eric Matthes <eric@Erics-Mac-Studio.local> Co-authored-by: Eric Matthes <eric@Erics-Mac-Studio.local>
1 parent e2e0159 commit 0a9b2d3

11 files changed

+267
-0
lines changed

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,5 @@ git-sim_media/
66
build/
77
dist/
88
git_sim.egg-info/
9+
10+
.venv/

tests/README.md

+73
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Testing
2+
---
3+
4+
Testing is done with pytest. The focus for now is on end-to-end tests, which show that the overall project is working as it should.
5+
6+
## Running tests
7+
8+
The following instructions will let you run tests as soon as you clone the repository:
9+
10+
```sh
11+
$ git clone https://github.com/initialcommit-com/git-sim.git
12+
$ cd git-sim
13+
$ python3 -m venv .venv
14+
$ source venv/bin/activate
15+
(.venv)$ pip install -e .
16+
(.venv)$ pip install pytest
17+
(.venv)$ pytest -s
18+
```
19+
20+
Including the `-s` flag tells pytest to include diagnostic information in the test output. This will show you where the test data is being written:
21+
22+
```sh
23+
(.venv)$ pytest -s
24+
===== test session starts ==========================================
25+
platform darwin -- Python 3.11.2, pytest-7.3.2, pluggy-1.0.0
26+
rootdir: /Users/.../git-sim
27+
collected 3 items
28+
29+
tests/e2e_tests/test_core_commands.py
30+
31+
Temp repo directory:
32+
/private/var/folders/.../pytest-108/sample_repo0
33+
34+
...
35+
36+
===== 3 passed in 6.58s ============================================
37+
```
38+
39+
## Helpful pytest notes
40+
41+
- `pytest -x`: Stop after the first test fails
42+
43+
## Adding more tests
44+
45+
To add another test:
46+
47+
- Work in `tests/e2e_tests/test_core_commands.py`.
48+
- Duplicate one of the existing test functions.
49+
- Replace the value of `raw_cmd` with the command you want to test.
50+
- Run the test suite once with `pytest -sx`. The test should fail, but it will generate the output you need to finish the process.
51+
- Look in the "Temp repo directory" specified at the start of the test output.
52+
- Find the `git-sim_media/` directory there, and find the output file that was generated for the test you just wrote.
53+
- Open that file, and make sure it's correct.
54+
- If it is, copy that file into `tests/e2e_tests/reference_files/`, with an appropriate name.
55+
- Update your new test function so that `fp_reference` points to this new reference file.
56+
- Run the test suite again, and your test should pass.
57+
- You will need to repeat this process once on macOS or Linux, and once on Windows.
58+
59+
## Cross-platform issues
60+
61+
There are two cross-platform issues to be aware of.
62+
63+
### Inconsistent png and jpg output
64+
65+
When git-sim generates a jpg or png file, that file can be slightly different on different systems. Files can be slightly different depending on the architecture, and which system libraries are installed. Even Intel and Apple-silicon Macs can end up generating non-identical image files.
66+
67+
These issues are mostly addressed by checking that image files are similar within a given threshold, rather than identical.
68+
69+
### Inconsistent Windows and macOS output
70+
71+
The differences across OSes is even greater. I believe this may have something to do with which fonts are available on each system.
72+
73+
This is dealt with by having Windows-specific reference files.

tests/e2e_tests/conftest.py

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import subprocess, os
2+
from pathlib import Path
3+
from shlex import split
4+
5+
import pytest
6+
7+
import utils
8+
9+
10+
@pytest.fixture(scope="session")
11+
def tmp_repo(tmp_path_factory):
12+
"""Create a copy of the sample repo, which we can run all tests against.
13+
14+
Returns: path to tmp dir containing sample test repository.
15+
"""
16+
17+
tmp_repo_dir = tmp_path_factory.mktemp("sample_repo")
18+
19+
# To see where tmp_repo_dir is located, run pytest with the `-s` flag.
20+
print(f"\n\nTemp repo directory:\n {tmp_repo_dir}\n")
21+
22+
# Create the sample repo for testing.
23+
os.chdir(tmp_repo_dir)
24+
25+
# When defining cmd, as_posix() is required for Windows compatibility.
26+
git_dummy_path = utils.get_venv_path() / "git-dummy"
27+
cmd = f"{git_dummy_path.as_posix()} --commits=10 --branches=4 --merge=1 --constant-sha --name=sample_repo --diverge-at=2"
28+
cmd_parts = split(cmd)
29+
subprocess.run(cmd_parts)
30+
31+
return tmp_repo_dir / "sample_repo"
Loading
Loading
Loading
Loading
Loading
Loading

tests/e2e_tests/test_core_commands.py

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Tests for the core commands implemented in git-sim.
2+
3+
All test runs use the -d flag to prevent images from opening automatically.
4+
5+
To induce failure, include a call to `run_git_reset()` in one of the
6+
test functions.
7+
"""
8+
9+
import os, subprocess
10+
from pathlib import Path
11+
12+
from utils import get_cmd_parts, compare_images, run_git_reset
13+
14+
15+
def test_log(tmp_repo):
16+
"""Test a simple `git-sim log` command."""
17+
raw_cmd = "git-sim log"
18+
cmd_parts = get_cmd_parts(raw_cmd)
19+
20+
os.chdir(tmp_repo)
21+
output = subprocess.run(cmd_parts, capture_output=True)
22+
23+
fp_generated = Path(output.stdout.decode().strip())
24+
fp_reference = Path(__file__).parent / "reference_files/git-sim-log.png"
25+
26+
assert compare_images(fp_generated, fp_reference)
27+
28+
29+
def test_status(tmp_repo):
30+
"""Test a simple `git-sim status` command."""
31+
raw_cmd = "git-sim status"
32+
cmd_parts = get_cmd_parts(raw_cmd)
33+
34+
os.chdir(tmp_repo)
35+
output = subprocess.run(cmd_parts, capture_output=True)
36+
37+
fp_generated = Path(output.stdout.decode().strip())
38+
fp_reference = Path(__file__).parent / "reference_files/git-sim-status.png"
39+
40+
assert compare_images(fp_generated, fp_reference)
41+
42+
43+
def test_merge(tmp_repo):
44+
"""Test a simple `git-sim merge` command."""
45+
raw_cmd = "git-sim merge branch2"
46+
cmd_parts = get_cmd_parts(raw_cmd)
47+
48+
os.chdir(tmp_repo)
49+
output = subprocess.run(cmd_parts, capture_output=True)
50+
51+
fp_generated = Path(output.stdout.decode().strip())
52+
fp_reference = Path(__file__).parent / "reference_files/git-sim-merge.png"
53+
54+
assert compare_images(fp_generated, fp_reference)

tests/e2e_tests/utils.py

+107
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import os, subprocess
2+
from pathlib import Path
3+
from shlex import split
4+
5+
import numpy as np
6+
7+
from PIL import Image, ImageChops
8+
9+
10+
def compare_images(path_gen, path_ref):
11+
"""Compare a generated image against a reference image.
12+
13+
This is a simple pixel-by-pixel comparison, with a threshold for
14+
an allowable difference.
15+
16+
Parameters: file path to generated and reference image files
17+
Returns: True/ False
18+
"""
19+
if os.name == "nt":
20+
# Use Windows-specific reference files.
21+
path_ref = path_ref.with_name(path_ref.stem + "_windows" + path_ref.suffix)
22+
23+
img_gen = Image.open(path_gen)
24+
img_ref = Image.open(path_ref)
25+
26+
img_diff = ImageChops.difference(img_gen, img_ref)
27+
28+
# We're only concerned with pixels that differ by a total of 20 or more
29+
# over all RGB values.
30+
# Convert the image data to a NumPy array for processing.
31+
data_diff = np.array(img_diff)
32+
33+
# Calculate the sum along the color axis (axis 2) and then check
34+
# if the sum is greater than or equal to 20. This will return a 2D
35+
# boolean array where True represents pixels that differ significantly.
36+
pixels_diff = np.sum(data_diff, axis=2) >= 20
37+
38+
# Calculate the ratio of pixels that differ significantly.
39+
ratio_diff = np.mean(pixels_diff)
40+
41+
# Images are similar if only a small % of pixels differ significantly.
42+
# This value can be increased if tests are failing when they shouldn't.
43+
# It can be decreased if tests are passing when they shouldn't.
44+
if ratio_diff < 0.0075:
45+
return True
46+
else:
47+
print("bad pixel ratio:", ratio_diff)
48+
return False
49+
50+
51+
def get_cmd_parts(raw_command):
52+
"""
53+
Convert a raw git-sim command to the full version we need to use
54+
when testing, then split the full command into parts for use in
55+
subprocess.run(). This allows test functions to explicitly state
56+
the actual command that users would run.
57+
58+
For example, the command:
59+
`git-sim log`
60+
becomes:
61+
`</path/to/git-sim> -d --output-only-path --img-format=png log`
62+
63+
This prevents images from auto-opening, simplifies parsing output to
64+
identify the images we need to check, and prefers png for test runs.
65+
66+
Returns: list of command parts, ready to be run with subprocess.run()
67+
"""
68+
# Add the global flags needed for testing.
69+
cmd = raw_command.replace(
70+
"git-sim", "git-sim -d --output-only-path --img-format=png"
71+
)
72+
73+
# Replace `git-sim` with the full path to the binary.
74+
# as_posix() is needed for Windows compatibility.
75+
git_sim_path = get_venv_path() / "git-sim"
76+
cmd = cmd.replace("git-sim", git_sim_path.as_posix())
77+
78+
return split(cmd)
79+
80+
81+
def run_git_reset(tmp_repo):
82+
"""Run `git reset`, in order to induce a failure.
83+
84+
This is particularly useful when testing the image comparison algorithm.
85+
- Running `git reset` makes many of the generated images different.
86+
- For example, `git-sim log` then generates a valid image, but it doesn't
87+
match the reference image.
88+
89+
Note: tmp_repo is a required argument, to make sure this command is not
90+
accidentally called in a different directory.
91+
"""
92+
cmd = "git reset --hard 60bce95465a890960adcacdcd7fa726d6fad4cf3"
93+
cmd_parts = split(cmd)
94+
95+
os.chdir(tmp_repo)
96+
subprocess.run(cmd_parts)
97+
98+
99+
def get_venv_path():
100+
"""Get the path to the active virtual environment.
101+
102+
We actually need the bin/ or Scripts/ dir, not just the path to venv/.
103+
"""
104+
if os.name == "nt":
105+
return Path(os.environ.get("VIRTUAL_ENV")) / "Scripts"
106+
else:
107+
return Path(os.environ.get("VIRTUAL_ENV")) / "bin"

0 commit comments

Comments
 (0)