-
Notifications
You must be signed in to change notification settings - Fork 568
/
Copy pathsnaptest.zig
323 lines (284 loc) · 10.8 KB
/
snaptest.zig
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
//! A tiny pattern/library for testing with expectations ([1], [2]).
//!
//! On a high level, this is a replacement for `std.testing.expectEqual` which:
//!
//! - is less cumbersome to use for complex types,
//! - gives somewhat more useful feedback on a test failure without much investment,
//! - drastically reduces the time to update the tests after refactors,
//! - encourages creation of reusable visualizations for data structures.
//!
//! Implementation-wise, `snaptest` provides a `Snap` type, which can be thought of as a Zig string
//! literal which also remembers its location in the source file, can be diffed with other strings,
//! and, crucially, can _update its own source code_ to match the expected value.
//!
//! Example usage:
//!
//! ```
//! const Snap = @import("snaptest.zig").Snap;
//! const snap = Snap.snap;
//!
//! fn check_addition(x: u32, y: u32, want: Snap) !void {
//! const got = x + y;
//! try want.diff_fmt("{}", .{got});
//! }
//!
//! test "addition" {
//! try check_addition(2, 2, snap(@src(),
//! \\8
//! ));
//! }
//! ```
//!
//! Running this test fails, printing the diff between actual result (`4`) and what's specified in
//! the source code.
//!
//! Re-running the test with `SNAP_UPDATE=1` environmental variable auto-magically updates the
//! source code to say `\\4`. Alternatively, you can use `snap(...).update()` to auto-update just a
//! single test.
//!
//! Note the `@src()` argument passed to the `snap(...)` invocation --- that's how it knows which
//! lines to update.
//!
//! Snapshots can use `<snap:ignore>` marker to ignore part of input:
//!
//! ```
//! test "time" {
//! var buf: [32]u8 = undefined;
//! const time = try std.fmt.bufPrint(&buf, "it's {}ms", .{
//! std.time.milliTimestamp(),
//! });
//! try Snap.snap(@src(),
//! \\it's <snap:ignore>ms
//! ).diff(time);
//! }
//! ```
//!
//! TODO:
//! - This doesn't actually `diff` things yet :o) But running with `SNAP_UPDATE=1` and then using
//! `git diff` is a workable substitute.
//! - Only one test can be updated at a time. To update several, we need to return
//! `error.SkipZigTest` on mismatch and adjust offsets appropriately.
//!
//! [1]: https://blog.janestreet.com/using-ascii-waveforms-to-test-hardware-designs/
//! [2]: https://ianthehenry.com/posts/my-kind-of-repl/
const std = @import("std");
const assert = std.debug.assert;
const builtin = @import("builtin");
const SourceLocation = std.builtin.SourceLocation;
const stdx = @import("../stdx.zig");
comptime {
assert(builtin.is_test);
}
// Set to `true` to update all snapshots.
const update_all: bool = false;
pub const Snap = struct {
location: SourceLocation,
text: []const u8,
update_this: bool = false,
/// Creates a new Snap.
///
/// For the update logic to work, *must* be formatted as:
///
/// ```
/// snap(@src(),
/// \\Text of the snapshot.
/// )
/// ```
pub fn snap(location: SourceLocation, text: []const u8) Snap {
return Snap{ .location = location, .text = text };
}
/// Builder-lite method to update just this particular snapshot.
pub fn update(snapshot: *const Snap) Snap {
return Snap{
.location = snapshot.location,
.text = snapshot.text,
.update_this = true,
};
}
/// To update a snapshot, use whichever you prefer:
/// - `.update()` method on a particular snap,
/// - `update_all` const in this file,
/// - `SNAP_UPDATE` env var.
fn should_update(snapshot: *const Snap) bool {
return snapshot.update_this or update_all or
std.process.hasEnvVarConstant("SNAP_UPDATE");
}
// Compare the snapshot with a formatted string.
pub fn diff_fmt(snapshot: *const Snap, comptime fmt: []const u8, fmt_args: anytype) !void {
const got = try std.fmt.allocPrint(std.testing.allocator, fmt, fmt_args);
defer std.testing.allocator.free(got);
try snapshot.diff(got);
}
// Compare the snapshot with the json serialization of a `value`.
pub fn diff_json(
snapshot: *const Snap,
value: anytype,
options: std.json.StringifyOptions,
) !void {
var got = std.ArrayList(u8).init(std.testing.allocator);
defer got.deinit();
try std.json.stringify(value, options, got.writer());
try snapshot.diff(got.items);
}
// Compare the snapshot with a given string.
pub fn diff(snapshot: *const Snap, got: []const u8) !void {
if (equal_excluding_ignored(got, snapshot.text)) return;
std.debug.print(
\\Snapshot differs.
\\Want:
\\----
\\{s}
\\----
\\Got:
\\----
\\{s}
\\----
\\
,
.{
snapshot.text,
got,
},
);
if (!snapshot.should_update()) {
std.debug.print(
"Rerun with SNAP_UPDATE=1 environmental variable to update the snapshot.\n",
.{},
);
return error.SnapDiff;
}
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const allocator = arena.allocator();
const file_text =
try std.fs.cwd().readFileAlloc(allocator, snapshot.location.file, 1024 * 1024);
var file_text_updated = try std.ArrayList(u8).initCapacity(allocator, file_text.len);
const line_zero_based = snapshot.location.line - 1;
const range = snap_range(file_text, line_zero_based);
const snapshot_prefix = file_text[0..range.start];
const snapshot_text = file_text[range.start..range.end];
const snapshot_suffix = file_text[range.end..];
const indent = get_indent(snapshot_text);
try file_text_updated.appendSlice(snapshot_prefix);
{
var lines = std.mem.split(u8, got, "\n");
while (lines.next()) |line| {
try file_text_updated.writer().print("{s}\\\\{s}\n", .{ indent, line });
}
}
try file_text_updated.appendSlice(snapshot_suffix);
try std.fs.cwd().writeFile(.{
.sub_path = snapshot.location.file,
.data = file_text_updated.items,
});
std.debug.print("Updated {s}\n", .{snapshot.location.file});
return error.SnapUpdated;
}
};
fn equal_excluding_ignored(got: []const u8, snapshot: []const u8) bool {
var got_rest = got;
var snapshot_rest = snapshot;
// Don't allow ignoring suffixes and prefixes, as that makes it easy to miss trailing or leading
// data.
assert(!std.mem.startsWith(u8, snapshot, "<snap:ignore>"));
assert(!std.mem.endsWith(u8, snapshot, "<snap:ignore>"));
for (0..10) |_| {
// Cut the part before the first ignore, it should be equal between two strings...
const snapshot_cut = stdx.cut(snapshot_rest, "<snap:ignore>") orelse break;
const got_cut = stdx.cut(got_rest, snapshot_cut.prefix) orelse return false;
if (got_cut.prefix.len != 0) return false;
got_rest = got_cut.suffix;
snapshot_rest = snapshot_cut.suffix;
// ...then find the next part that should match, and cut up to that.
const next_match = if (stdx.cut(snapshot_rest, "<snap:ignore>")) |snapshot_cut_next|
snapshot_cut_next.prefix
else
snapshot_rest;
assert(next_match.len > 0);
snapshot_rest = stdx.cut(snapshot_rest, next_match).?.suffix;
const got_cut_next = stdx.cut(got_rest, next_match) orelse return false;
const ignored = got_cut_next.prefix;
// If <snap:ignore> matched an empty string, or several lines, report it as an error.
if (ignored.len == 0) return false;
if (std.mem.indexOf(u8, ignored, "\n") != null) return false;
got_rest = got_cut_next.suffix;
} else @panic("more than 10 ignores");
return std.mem.eql(u8, got_rest, snapshot_rest);
}
test equal_excluding_ignored {
const TestCase = struct { got: []const u8, snapshot: []const u8 };
const cases_ok: []const TestCase = &.{
.{ .got = "ABA", .snapshot = "ABA" },
.{ .got = "ABBA", .snapshot = "A<snap:ignore>A" },
.{ .got = "ABBACABA", .snapshot = "AB<snap:ignore>CA<snap:ignore>A" },
};
for (cases_ok) |case| {
try std.testing.expect(equal_excluding_ignored(case.got, case.snapshot));
}
const cases_err: []const TestCase = &.{
.{ .got = "ABA", .snapshot = "ACA" },
.{ .got = "ABBA", .snapshot = "A<snap:ignore>C" },
.{ .got = "ABBACABA", .snapshot = "AB<snap:ignore>DA<snap:ignore>BA" },
.{ .got = "ABBACABA", .snapshot = "AB<snap:ignore>BA<snap:ignore>DA" },
.{ .got = "ABA", .snapshot = "AB<snap:ignore>A" },
.{ .got = "A\nB\nA", .snapshot = "A<snap:ignore>A" },
};
for (cases_err) |case| {
try std.testing.expect(!equal_excluding_ignored(case.got, case.snapshot));
}
}
const Range = struct { start: usize, end: usize };
/// Extracts the range of the snapshot. Assumes that the snapshot is formatted as
///
/// ```
/// snap(@src(),
/// \\first line
/// \\second line
/// )
/// ```
///
/// We could make this more robust by using `std.zig.Ast`, but sticking to manual string processing
/// is simpler, and enforced consistent style of snapshots is a good thing.
///
/// While we expect to find a snapshot after a given line, this is not guaranteed (the file could
/// have been modified between compilation and running the test), but should be rare enough to
/// just fail with an assertion.
fn snap_range(text: []const u8, src_line: u32) Range {
var offset: usize = 0;
var line_number: u32 = 0;
var lines = std.mem.split(u8, text, "\n");
const snap_start = while (lines.next()) |line| : (line_number += 1) {
if (line_number == src_line) {
assert(std.mem.indexOf(u8, line, "@src()") != null);
}
if (line_number == src_line + 1) {
assert(is_multiline_string(line));
break offset;
}
offset += line.len + 1; // 1 for \n
} else unreachable;
lines = std.mem.split(u8, text[snap_start..], "\n");
const snap_end = while (lines.next()) |line| {
if (!is_multiline_string(line)) {
break offset;
}
offset += line.len + 1; // 1 for \n
} else unreachable;
return Range{ .start = snap_start, .end = snap_end };
}
fn is_multiline_string(line: []const u8) bool {
for (line, 0..) |c, i| {
switch (c) {
' ' => {},
'\\' => return (i + 1 < line.len and line[i + 1] == '\\'),
else => return false,
}
}
return false;
}
fn get_indent(line: []const u8) []const u8 {
for (line, 0..) |c, i| {
if (c != ' ') return line[0..i];
}
return line;
}