diff --git a/.travis.yml b/.travis.yml
index b9c764b5..8075f52f 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -61,6 +61,7 @@ jobs:
     allow_failures:
         -   stage: Static Analysis (informative)
         -   stage: Code Coverage
+        -   php: nightly
 
 
 dist: xenial
diff --git a/src/CodeCoverage/Collector.php b/src/CodeCoverage/Collector.php
index 1e80412d..585b6b52 100644
--- a/src/CodeCoverage/Collector.php
+++ b/src/CodeCoverage/Collector.php
@@ -30,9 +30,9 @@ class Collector
 	public static function detectEngines(): array
 	{
 		return array_filter([
-			extension_loaded('pcov') ? self::ENGINE_PCOV : null,
-			defined('PHPDBG_VERSION') ? self::ENGINE_PHPDBG : null,
-			extension_loaded('xdebug') ? self::ENGINE_XDEBUG : null,
+			extension_loaded('pcov') ? [self::ENGINE_PCOV, phpversion('pcov')] : null,
+			defined('PHPDBG_VERSION') ? [self::ENGINE_PHPDBG, PHPDBG_VERSION] : null,
+			extension_loaded('xdebug') ? [self::ENGINE_XDEBUG, phpversion('xdebug')] : null,
 		]);
 	}
 
@@ -52,7 +52,11 @@ public static function start(string $file, string $engine): void
 		if (self::isStarted()) {
 			throw new \LogicException('Code coverage collector has been already started.');
 
-		} elseif (!in_array($engine, self::detectEngines(), true)) {
+		} elseif (!in_array(
+			$engine,
+			array_map(function (array $engineInfo) { return $engineInfo[0]; }, self::detectEngines()),
+			true
+		)) {
 			throw new \LogicException("Code coverage engine '$engine' is not supported.");
 		}
 
diff --git a/src/CodeCoverage/Generators/template.phtml b/src/CodeCoverage/Generators/template.phtml
index 4db2a3e3..0fb4e126 100644
--- a/src/CodeCoverage/Generators/template.phtml
+++ b/src/CodeCoverage/Generators/template.phtml
@@ -10,12 +10,12 @@
 	<style type="text/css">
 	html {
 		font: 14px/1.5 Verdana,"Geneva CE",lucida,sans-serif;
-		border-top: 4.7em solid #f4ebdb;
+		border-top: 7.7em solid #f4ebdb;
 	}
 
 	body {
 		max-width: 990px;
-		margin: -4.7em auto 0;
+		margin: -7.7em auto 0;
 		background: #fcfaf5;
 		color: #333;
 	}
@@ -27,11 +27,15 @@
 	h1 {
 		font-family: "Trebuchet MS","Geneva CE",lucida,sans-serif;
 		font-size: 1.9em;
-		margin: .5em .5em 1.5em;
+		margin: .5em;
 		color: #7a7772;
 		text-shadow: 1px 1px 0 white;
 	}
 
+	h1 small {
+		font-size: 60%;
+	}
+
 	div.code {
 		background: white;
 		border: 1px dotted silver;
@@ -64,6 +68,7 @@
 
 	aside a {
 		color: #c0c0c0;
+		display: block;
 	}
 
 	aside a:hover {
@@ -78,6 +83,10 @@
 		position: relative;
 	}
 
+	i {
+		font-style: normal;
+	}
+
 	a {
 		color: #006aeb;
 		text-decoration: none;
@@ -108,6 +117,13 @@
 		height: 1em;
 	}
 
+	.bar-empty {
+		border: 1px solid transparent;
+		background: transparent;
+		width: 35px;
+		height: 1em;
+	}
+
 	.bar div {
 		background: #1a7e1e;
 		height: 1em;
@@ -133,78 +149,420 @@
 	code .hh { color: #06B; }
 	code .hk { color: #e71818; }
 	code .hs { color: #008000; }
+
+	.tabs {
+		margin-bottom: 1.5em;
+	}
+
+	.tabs a {
+		padding: .5em 1.5em;
+		display: inline-block;
+		font-size: 17px;
+	}
+
+	.tabs a.active,
+	.tabs a:focus,
+	.tabs a:hover {
+		background-color: #fcfaf5;
+		text-decoration: none;
+	}
+
+	.directory {
+		display: none;
+		margin-left: 1.5rem;
+	}
+
+	.directory.opened {
+		display: block;
+	}
+
+	.toggle-dir {
+		color: brown;
+	}
+
+	.toggle-dir:before {
+		content: '▼ ';
+	}
+
+	.toggle-dir.opened:before {
+		content: '▲ ';
+	}
+
+	.sort .up, .sort .no-sort, .sort .down {
+		display: inline-block;
+		padding-left: 5px;
+		color: #c9302c
+	}
+
+	.sort .no-sort {
+		color: #ccc
+	}
+
+	.sort .up, .sort .down, .sort .no-sort,
+	.sort.desc:hover .up, .sort.desc:hover .down,
+	.sort.asc .no-sort, .sort.down .no-sort, .sort.asc:hover .up, .sort:hover .no-sort {
+		display: none
+	}
+
+	.sort:hover .up, .sort.asc .up,
+	.sort.desc:hover .no-sort,
+	.sort.asc:hover .down, .sort.desc .down {
+		display: inline-block
+	}
 	</style>
 </head>
 
 <body>
-	<h1><?= $title ? htmlspecialchars("$title - ") : ''; ?>Code coverage <?= round($coveredPercent) ?>&nbsp;%</h1>
 
-	<?php foreach ($files as $id => $info): ?>
-	<div>
+<?php
+
+function assignArrayByPath(&$arr, $info, $value, $separator = '/') {
+	$keys = explode($separator, $info->name);
+
+	$currentFile = '';
+	foreach ($keys as $key) {
+		$currentFile = $currentFile . ($currentFile !== '' ? '/' : '') . $key;
+		$arr = &$arr['files'][$key];
+
+		if (!isset($arr['name'])) {
+			$arr['name'] = $currentFile;
+		}
+		$arr['count'] = isset($arr['count']) ? $arr['count'] + 1 : 1;
+		$arr['coverage'] = isset($arr['coverage']) ? $arr['coverage'] + $info->coverage : $info->coverage;
+	}
+	$arr = $value;
+}
+
+$jsonData = [];
+$directories = [];
+$allLinesCount = 0;
+foreach ($files as $id => $info) {
+	$code = file_get_contents($info->file);
+	$lineCount = substr_count($code, "\n") + 1;
+	$digits = ceil(log10($lineCount)) + 1;
+
+	$allLinesCount += $lineCount;
+
+	$currentId = "F{$id}";
+	assignArrayByPath($directories, $info, $currentId);
+
+	$data = (array) $info;
+	$data['digits'] = $digits;
+	$data['lineCount'] = $lineCount;
+	$data['content'] = strtr(highlight_string($code, true), [
+		'<code>' => "<code style='margin-left: {$digits}em'>",
+		'<span style="color: ' => '<i class="',
+		'</span>' => '</i>',
+		'<br />' => '<br>',
+	]);
+	$jsonData[$currentId] = $data;
+} ?>
+
+<h1>
+	<?= $title ? htmlspecialchars("$title - ") : ''; ?>Code coverage <?= round($coveredPercent) ?>&nbsp;%
+	<small>sources have <?= number_format($allLinesCount) ?> lines of code in <?= number_format(count($files)) ?> files</small>
+</h1>
+
+<nav class="tabs">
+	<a href="#list" class="tab-item active">List</a>
+	<a href="#tree" class="tab-item">Tree</a>
+</nav>
+
+<div class="tabs-content">
+	<div id="list" class="tab-content">
 		<table>
-		<tr<?= $info->class ? " class='$info->class'" : '' ?>>
-			<td class="number"><small><?= $info->coverage ?> %</small></td>
-			<td><div class="bar"><div style="width: <?= $info->coverage ?>%"></div></div></td>
-			<td><a href="#F<?= $id ?>" class="toggle"><?= $info->name ?></a></td>
-		</tr>
+			<tbody>
+			<tr>
+				<td class="number">
+					<a href="#" class="sort desc" data-sort="coverage"><span class="up">↓</span><span class="down">↑</span><span class="no-sort">&nbsp;</span>%</a>
+				</td>
+				<td>
+					<div class="bar-empty"></div>
+				</td>
+				<td>
+					<a href="#" class="sort" data-sort="path">path<span class="up">↓</span><span class="down">↑</span><span class="no-sort">&nbsp;</span></a>
+				</td>
+			</tr>
+			</tbody>
 		</table>
+		<div id="files-content"></div>
+	</div>
+	<div id="tree" class="tab-content" style="display: none"></div>
+</div>
+
+<footer>
+	<p>Generated by <a href="https://tester.nette.org">Nette Tester</a> at <?= @date('Y/m/d H:i:s') // @ timezone may not be set ?></p>
+</footer>
+
+<script>
+	(function() {
+		let _files = <?= json_encode($jsonData) ?>;
+		let _directories = <?= json_encode($directories) ?>;
+		let isFilesInitialized = false;
+		let isDirectoriesInitialized = false;
+
+		let sort = 'coverage';
+		let desc = true;
+		let classes = {
+			'1': 't', // tested
+			'-1': 'u', // untested
+			'-2': 'dead', // dead code
+		};
+
+		function getSortedKeys() {
+			switch (sort) {
+				case 'coverage':
+					let sortedFiles = [];
+					for (let i in _files) {
+						sortedFiles.push([i, _files[i].coverage]);
+					}
+
+					sortedFiles.sort(function(a, b) {
+						return desc ? (b[1] - a[1]) : (a[1] - b[1]);
+					});
+
+					let filesByCoverage = [];
+					for (let i = 0; i < sortedFiles.length; i++) {
+						filesByCoverage.push(sortedFiles[i][0]);
+					}
+					return filesByCoverage;
+
+				case 'path':
+					return sortAlphabetically(_files, desc);
+
+				default:
+					return Object.keys(_files).sort((a, b) => desc ? (b-a) : (a-b));
+			}
+		}
+
+		function sortAlphabetically(obj, isDesc) {
+			let sorted = [];
+			for (let i in obj) {
+				sorted.push([i, obj[i].name]);
+			}
 
-		<div class="code" id="F<?= $id ?>">
-		<div>
-			<aside>
-			<?php
-			$code = file_get_contents($info->file);
-			$lineCount = substr_count($code, "\n") + 1;
-			$digits = ceil(log10($lineCount)) + 1;
-
-			$prevClass = null;
-			$closeTag = $buffer = '';
-			for ($i = 1; $i < $lineCount; $i++) {
-				$class = isset($info->lines[$i]) && isset($classes[$info->lines[$i]]) ? $classes[$info->lines[$i]] : '';
-				if ($class !== $prevClass) {
-					echo rtrim($buffer) . $closeTag;
-					$buffer = '';
-					$closeTag = '</div>';
-					echo '<div' . ($class ? " class='$class'" : '') . '>';
+			sorted.sort(function(a, b) {
+				let compared = a[1].localeCompare(b[1]);
+				return compared === -1 ? (isDesc ? 1 : -1) : (compared === 1 ? (isDesc ? -1 : 1) : 0);
+			});
+
+			let out = [];
+			for (let i = 0; i < sorted.length; i++) {
+				out.push(sorted[i][0]);
+			}
+			return out;
+		}
+
+		function initDirectories() {
+			if (isDirectoriesInitialized) {
+				return;
+			}
+			let el = document.getElementById('tree');
+			el.innerHTML = createDirectory(_directories.files);
+			isDirectoriesInitialized = true;
+		}
+
+		function createDirectory(directory) {
+			let out = '';
+			Object.keys(directory).sort(function(a, b) {
+				let aIsFile = a.endsWith('.php');
+				let bIsFile = b.endsWith('.php');
+				if ((aIsFile || bIsFile) && !(aIsFile && bIsFile)) {
+					return aIsFile ? 1 : -1;
+				}
+				let compared = a.localeCompare(b);
+				return compared === -1 ? -1 : (compared === 1 ? 1 : 0);
+			}).forEach(function(i) {
+				let dir = directory[i];
+				let id, coverage;
+				let isFile = typeof dir === 'string';
+				if (isFile) {
+					id = dir;
+					dir = _files[dir];
+					coverage = dir.coverage;
+				} else {
+					id = null;
+					coverage = dir.count === 0 ? 0 : Math.round(dir.coverage / dir.count);
 				}
 
-				$buffer .= "<a href='#F{$id}L{$i}' id='F{$id}L{$i}'>" . sprintf("%{$digits}s", $i) . "</a>\n";
-				$prevClass = $class;
+				let html = createFileInfo(isFile ? ('D' + id) : dir.name, {
+					coverage: coverage,
+					name: isFile ? i : (i + ' <small>(' + dir.count + ')</small>'),
+					class: isFile ? dir.class : 'dir',
+				}, isFile ? 'toggle' : 'toggle-dir');
+
+				if (isFile) {
+					html += '<div class="code" id="D' + id + '"></div>';
+				} else {
+					html += '<div class="directory" id="' + dir.name + '">' + createDirectory(dir.files) + '</div>';
+				}
+
+				out += '<div>' + html + '</div>';
+			});
+			return out;
+		}
+
+		function initFiles(force) {
+			if (!force && isFilesInitialized) {
+				return;
 			}
-			echo $buffer . $closeTag;
+			let filesEl = document.getElementById('files-content');
+			filesEl.innerHTML = '';
 
-			$code = strtr(highlight_string($code, true), [
-				'<code>' => "<code style='margin-left: {$digits}em'>",
-				'<span style="color: ' => '<span class="',
-			]);
-			?>
-			</aside>
-			<?= $code ?>
-		</div>
-		</div>
-	</div>
-	<?php endforeach ?>
+			getSortedKeys().forEach(function(i) {
+				let file = _files[i];
+				let html = createFileInfo(i, file);
+
+				html += '<div class="code" id="' + i + '"></div>';
+
+				let wrapper = document.createElement('div');
+				wrapper.innerHTML = html;
+				filesEl.appendChild(wrapper);
+			});
+			isFilesInitialized = true;
+		}
+
+		function createFileInfo(id, file, toggleClass) {
+			toggleClass = toggleClass || 'toggle';
+			return '<table>' +
+				'<tbody><tr' + (file.class ? (' class="' + file.class + '"') : '') + '>' +
+				'<td class="number"><small>' + file.coverage + ' %</small></td>' +
+				'<td><div class="bar"><div style="width: ' + file.coverage + '%"></div></div></td>' +
+				'<td><a href="#' + id + '" class="' + toggleClass + '">' + file.name + '</a></td>' +
+				'</tr>' +
+				'</tbody></table>';
+		}
+
+		function initFileContent(el) {
+			if (el.hasAttribute('data-initialized')) {
+				return;
+			}
+
+			let id = el.getAttribute('id');
+			id = id.startsWith('D') ? id.split('D')[1] : id;
+			let file = _files[id];
+			if (!file) {
+				return;
+			}
+
+			let aside = '';
+			let prevClass = null;
+			let closeTag = '', buffer = '';
+			for (let i = 1; i < file.lineCount; i++) {
+				let currentClass = file.lines[i] && classes[file.lines[i]] ? classes[file.lines[i]] : '';
+				if (currentClass !== prevClass) {
+					aside += (buffer + closeTag);
+					buffer = '';
+					closeTag = '</div>';
+					aside += '<div' + (currentClass ? (' class="' + currentClass + '"') : '') + '>';
+				}
+
+				let currentId = id + 'L' + i;
+				buffer += '<a href="#' + currentId + '" id="' + currentId + '">' + '&nbsp;'.repeat(file.digits - String(i).length) + i + "</a>";
+				prevClass = currentClass;
+			}
+			aside += buffer + closeTag;
+
+			el.innerHTML = '<div><aside>' + aside + '</aside>' + (file ? file.content : 'ERROR: Not available') + '</div>';
+
+			el.setAttribute('data-initialized', 'true');
+		}
+
+		function addClickListener(className, callback) {
+			document.body.addEventListener('click', function (e) {
+				let isParent = e.target.parentNode && e.target.parentNode.classList.contains(className);
+				if (isParent || e.target.classList.contains(className)) {
+					callback.apply(isParent ? e.target.parentNode : e.target, [e]);
+				}
+			});
+		}
+
+		addClickListener('sort', function(e) {
+			e.preventDefault();
+			sort = this.getAttribute('data-sort');
+
+			let sortItems = document.getElementsByClassName('sort');
+			for (let i = 0; i < sortItems.length; i++) {
+				if (sortItems[i] === this) {
+					continue;
+				}
+				sortItems[i].classList.remove('asc');
+				sortItems[i].classList.remove('desc');
+			}
 
-	<footer>
-		<p>Generated by <a href="https://tester.nette.org">Nette Tester</a> at <?= @date('Y/m/d H:i:s') // @ timezone may not be set ?></p>
-	</footer>
+			desc = false;
+			if (this.classList.contains('asc')) {
+				desc = true;
+				this.classList.remove('asc');
+				this.classList.add('desc');
+			} else {
+				this.classList.remove('desc');
+				this.classList.add('asc');
+			}
 
-	<script>
-	document.body.addEventListener('click', function (e) {
-		if (e.target.className === 'toggle') {
-			var el = document.getElementById(e.target.href.split('#')[1]);
+			initFiles(true);
+		});
+
+		addClickListener('toggle', function(e) {
+			e.preventDefault();
+			let el = document.getElementById(this.href.split('#')[1]);
 			if (el.style.display === 'block') {
 				el.style.display = 'none';
 			} else {
+				initFileContent(el);
 				el.style.display = 'block';
 			}
+		});
+
+		addClickListener('toggle-dir', function(e) {
 			e.preventDefault();
-		}
-	});
+			let el = document.getElementById(this.href.split('#')[1]);
+			if (el.classList.contains('opened')) {
+				this.classList.remove('opened');
+				el.classList.remove('opened');
+			} else {
+				this.classList.add('opened');
+				el.classList.add('opened');
+			}
+		});
 
-	if (el = document.getElementById(window.location.hash.replace(/^#|L\d+$/g, ''))) {
-		el.style.display = 'block';
-	}
-	</script>
+		addClickListener('tab-item', function(e) {
+			e.preventDefault();
+			let tabs = document.getElementsByClassName('tab-content');
+			for (let i = 0; i < tabs.length; i++) {
+				tabs[i].style.display = 'none';
+			}
+			let tabItems = document.getElementsByClassName('tab-item');
+			for (let i = 0; i < tabItems.length; i++) {
+				tabItems[i].classList.remove('active');
+			}
+			this.classList.add('active');
+
+			let id = this.href.split('#')[1];
+			if (id === 'files') {
+				initFiles();
+			} else {
+				initDirectories();
+			}
+
+			let el = document.getElementById(id);
+			if (el.style.display === 'block') {
+				el.style.display = 'none';
+			} else {
+				el.style.display = 'block';
+			}
+		});
+
+		document.addEventListener("DOMContentLoaded", function(event) {
+			initFiles(true);
+
+			let el = document.getElementById(window.location.hash.replace(/^#|L\d+$/g, ''));
+			if (el) {
+				initFileContent(el);
+				el.style.display = 'block';
+			}
+		});
+	})();
+</script>
 </body>
 </html>
diff --git a/src/Framework/Assert.php b/src/Framework/Assert.php
index 1ae8d16f..741a37d9 100644
--- a/src/Framework/Assert.php
+++ b/src/Framework/Assert.php
@@ -442,6 +442,40 @@ public static function matchFile(string $file, $actual, string $description = nu
 	}
 
 
+	/**
+	 * Compares value with a previously created snapshot.
+	 */
+	public static function snapshot(string $snapshotName, $actual, string $description = null): void
+	{
+		self::$counter++;
+
+		$snapshot = new Snapshot($snapshotName);
+		if (!$snapshot->exists()) {
+			if (!$snapshot->canUpdate()) {
+				self::fail("Missing snapshot '$snapshotName', use --update-snapshots option to generate it.");
+			}
+
+			$snapshot->update($actual);
+		}
+
+		$expected = $snapshot->read();
+		if ($expected !== $actual) {
+			if (!$snapshot->canUpdate()) {
+				self::fail(
+					self::describe(
+						"%1 should be %2 in snapshot '$snapshotName'",
+						$description
+					),
+					$actual,
+					$expected
+				);
+			}
+
+			$snapshot->update($actual);
+		}
+	}
+
+
 	/**
 	 * Assertion that fails.
 	 */
diff --git a/src/Framework/Environment.php b/src/Framework/Environment.php
index 150b14b4..759dc1bf 100644
--- a/src/Framework/Environment.php
+++ b/src/Framework/Environment.php
@@ -30,6 +30,9 @@ class Environment
 	/** Thread number when run tests in multi threads */
 	public const THREAD = 'NETTE_TESTER_THREAD';
 
+	/** Should Tester update snapshots? */
+	public const UPDATE_SNAPSHOTS = 'NETTE_TESTER_UPDATE_SNAPSHOTS';
+
 	/** @var bool */
 	public static $checkAssertions = false;
 
@@ -126,6 +129,11 @@ public static function setupErrors(): void
 						self::removeOutputBuffers();
 						echo "\n", Dumper::color('white/red', "Fatal error: $error[message] in $error[file] on line $error[line]"), "\n";
 					}
+				} elseif (getenv(self::UPDATE_SNAPSHOTS) && Snapshot::$updatedSnapshots) {
+					self::removeOutputBuffers();
+					echo "\nThe following snapshots were updated, please make sure they are correct:\n"
+						. implode("\n", Snapshot::$updatedSnapshots) . "\n";
+					exit(Runner\Job::CODE_FAIL);
 				} elseif (self::$checkAssertions && !Assert::$counter) {
 					self::removeOutputBuffers();
 					echo "\n", Dumper::color('white/red', 'Error: This test forgets to execute an assertion.'), "\n";
diff --git a/src/Framework/Snapshot.php b/src/Framework/Snapshot.php
new file mode 100644
index 00000000..c9950226
--- /dev/null
+++ b/src/Framework/Snapshot.php
@@ -0,0 +1,102 @@
+<?php
+
+/**
+ * This file is part of the Nette Tester.
+ * Copyright (c) 2009 David Grudl (https://davidgrudl.com)
+ */
+
+declare(strict_types=1);
+
+namespace Tester;
+
+
+/**
+ * Snapshot of a tested value.
+ * @internal
+ */
+class Snapshot
+{
+	/** @var string */
+	public static $snapshotDir = 'snapshots';
+
+	/** @var string[] */
+	public static $updatedSnapshots = [];
+
+	/** @var string[] */
+	private static $usedNames = [];
+
+	/** @var string */
+	private $name;
+
+
+	public function __construct(string $name)
+	{
+		if (!preg_match('/^[a-zA-Z0-9-_]+$/', $name)) {
+			throw new \Exception("Invalid snapshot name '$name'. Only alphanumeric characters, dash and underscore are allowed.");
+		}
+
+		if (in_array($name, self::$usedNames, true)) {
+			throw new \Exception("Snapshot '$name' was already asserted, please use a different name.");
+		}
+
+		$this->name = self::$usedNames[] = $name;
+	}
+
+
+	public function exists(): bool
+	{
+		return file_exists($this->getSnapshotFile());
+	}
+
+
+	public function read()
+	{
+		$snapshotFile = $this->getSnapshotFile();
+		set_error_handler(function ($errno, $errstr) use ($snapshotFile) {
+			throw new \Exception("Unable to read snapshot file '$snapshotFile': $errstr");
+		});
+
+		$snapshotContents = include $snapshotFile;
+
+		restore_error_handler();
+		return $snapshotContents;
+	}
+
+
+	public function canUpdate(): bool
+	{
+		return (bool) getenv(Environment::UPDATE_SNAPSHOTS);
+	}
+
+
+	public function update($value): void
+	{
+		if (!$this->canUpdate()) {
+			throw new \Exception('Cannot update snapshot. Please run tests again with --update-snapshots.');
+		}
+
+		$snapshotFile = $this->getSnapshotFile();
+		$snapshotDirectory = dirname($snapshotFile);
+		if (!is_dir($snapshotDirectory) && !mkdir($snapshotDirectory)) {
+			throw new \Exception("Unable to create snapshot directory '$snapshotDirectory'.");
+		}
+
+		$snapshotContents = '<?php return ' . var_export($value, true) . ';' . PHP_EOL;
+		if (file_put_contents($snapshotFile, $snapshotContents) === false) {
+			throw new \Exception("Unable to write snapshot file '$snapshotFile'.");
+		}
+
+		self::$updatedSnapshots[] = $snapshotFile;
+	}
+
+
+	private function getSnapshotFile(): string
+	{
+		$testFile = $_SERVER['argv'][0];
+		$path = self::$snapshotDir . DIRECTORY_SEPARATOR . pathinfo($testFile, PATHINFO_FILENAME) . '.' . $this->name . '.phps';
+		if (!preg_match('#/|\w:#A', self::$snapshotDir)) {
+			$path = dirname($testFile) . DIRECTORY_SEPARATOR . $path;
+		}
+		return $path;
+	}
+}
diff --git a/src/Runner/CliTester.php b/src/Runner/CliTester.php
index cbf96033..ccd2e6d2 100644
--- a/src/Runner/CliTester.php
+++ b/src/Runner/CliTester.php
@@ -66,6 +66,9 @@ public function run(): ?int
 		if ($this->options['--coverage']) {
 			$coverageFile = $this->prepareCodeCoverage($runner);
 		}
+		if ($this->options['--update-snapshots']) {
+			$runner->setEnvironmentVariable(Environment::UPDATE_SNAPSHOTS, '1');
+		}
 
 		if ($this->options['-o'] !== null) {
 			ob_clean();
@@ -118,6 +121,7 @@ private function loadOptions(): CommandLine
     --colors [1|0]               Enable or disable colors.
     --coverage <path>            Generate code coverage report to file.
     --coverage-src <path>        Path to source code.
+    --update-snapshots           Create or update snapshot files.
     -h | --help                  This help.
 
 XX
@@ -236,8 +240,14 @@ private function prepareCodeCoverage(Runner $runner): string
 		file_put_contents($this->options['--coverage'], '');
 		$file = realpath($this->options['--coverage']);
 
+		[$engine, $version] = reset($engines);
+
 		$runner->setEnvironmentVariable(Environment::COVERAGE, $file);
-		$runner->setEnvironmentVariable(Environment::COVERAGE_ENGINE, $engine = reset($engines));
+		$runner->setEnvironmentVariable(Environment::COVERAGE_ENGINE, $engine);
+
+		if ($engine === CodeCoverage\Collector::ENGINE_XDEBUG && version_compare($version, '3.0.0', '>=')) {
+			$runner->addPhpIniOption('xdebug.mode', ltrim(ini_get('xdebug.mode') . ',coverage', ','));
+		}
 
 		if ($engine === CodeCoverage\Collector::ENGINE_PCOV && count($this->options['--coverage-src'])) {
 			$runner->addPhpIniOption('pcov.directory', Helpers::findCommonDirectory($this->options['--coverage-src']));
diff --git a/src/Runner/info.php b/src/Runner/info.php
index c00d4521..a2c2b012 100644
--- a/src/Runner/info.php
+++ b/src/Runner/info.php
@@ -36,7 +36,9 @@
 	'PHP version' . ($isPhpDbg ? '; PHPDBG version' : '')
 		=> "$info->version ($info->sapi)" . ($isPhpDbg ? "; $info->phpDbgVersion" : ''),
 	'Loaded php.ini files' => count($info->iniFiles) ? implode(', ', $info->iniFiles) : '(none)',
-	'Code coverage engines' => count($info->codeCoverageEngines) ? implode(', ', $info->codeCoverageEngines) : '(not available)',
+	'Code coverage engines' => count($info->codeCoverageEngines)
+		? implode(', ', array_map(function (array $engineInfo) { return sprintf('%s (%s)', ...$engineInfo); }, $info->codeCoverageEngines))
+		: '(not available)',
 	'PHP temporary directory' => $info->tempDir == '' ? '(empty)' : $info->tempDir,
 	'Loaded extensions' => count($info->extensions) ? implode(', ', $info->extensions) : '(none)',
 ] as $title => $value) {
diff --git a/src/bootstrap.php b/src/bootstrap.php
index 065a4c7e..b18444e1 100644
--- a/src/bootstrap.php
+++ b/src/bootstrap.php
@@ -16,6 +16,7 @@
 require __DIR__ . '/Framework/TestCase.php';
 require __DIR__ . '/Framework/FileMutator.php';
 require __DIR__ . '/Framework/Expect.php';
+require __DIR__ . '/Framework/Snapshot.php';
 require __DIR__ . '/CodeCoverage/Collector.php';
 require __DIR__ . '/Runner/Job.php';
 
diff --git a/tests/CodeCoverage/Collector.phpt b/tests/CodeCoverage/Collector.phpt
index a6e4d3bb..29074d60 100644
--- a/tests/CodeCoverage/Collector.phpt
+++ b/tests/CodeCoverage/Collector.phpt
@@ -9,13 +9,21 @@ use Tester\FileMock;
 require __DIR__ . '/../bootstrap.php';
 
 
-$engines = array_filter(CodeCoverage\Collector::detectEngines(), function (string $engine) {
+$engines = array_filter(CodeCoverage\Collector::detectEngines(), function (array $engineInfo) {
+	[$engine] = $engineInfo;
 	return $engine !== CodeCoverage\Collector::ENGINE_PCOV; // PCOV needs system pcov.directory INI to be set
 });
 if (count($engines) < 1) {
 	Tester\Environment::skip('Requires Xdebug or PHPDB SAPI.');
 }
-$engine = reset($engines);
+[$engine, $version] = reset($engines);
+
+if ($engine === CodeCoverage\Collector::ENGINE_XDEBUG
+	&& version_compare($version, '3.0.0', '>=')
+	&& strpos(ini_get('xdebug.mode'), 'coverage') === false
+) {
+	Tester\Environment::skip('Requires xdebug.mode=coverage with Xdebug 3.');
+}
 
 if (CodeCoverage\Collector::isStarted()) {
 	Tester\Environment::skip('Requires running without --coverage.');
diff --git a/tests/Framework/.gitignore b/tests/Framework/.gitignore
new file mode 100644
index 00000000..373f5f3d
--- /dev/null
+++ b/tests/Framework/.gitignore
@@ -0,0 +1 @@
+snapshots/
diff --git a/tests/Framework/Assert.snapshot.phpt b/tests/Framework/Assert.snapshot.phpt
new file mode 100644
index 00000000..6e000212
--- /dev/null
+++ b/tests/Framework/Assert.snapshot.phpt
@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+use Tester\Assert;
+use Tester\AssertException;
+use Tester\Snapshot;
+
+require __DIR__ . '/../bootstrap.php';
+
+Snapshot::$snapshotDir = __DIR__ . DIRECTORY_SEPARATOR . 'fixtures';
+
+Assert::snapshot('existingSnapshot', ['answer' => 42]);
+
+Assert::exception(function () {
+	Assert::snapshot('invalid / name', ['answer' => 42]);
+}, \Exception::class, "Invalid snapshot name 'invalid / name'. Only alphanumeric characters, dash and underscore are allowed.");
+
+Assert::exception(function () {
+	Assert::snapshot('existingSnapshot', ['answer' => 42]);
+}, \Exception::class, "Snapshot 'existingSnapshot' was already asserted, please use a different name.");
+
+Assert::exception(function () {
+	Assert::snapshot('anotherSnapshot', ['answer' => 43]);
+}, AssertException::class, "%a% should be %a% in snapshot 'anotherSnapshot'");
+
+Assert::exception(function () {
+	Assert::snapshot('nonExistingSnapshot', 'value');
+}, AssertException::class, "Missing snapshot 'nonExistingSnapshot', use --update-snapshots option to generate it.");
diff --git a/tests/Framework/Assert.snapshot.update.phpt b/tests/Framework/Assert.snapshot.update.phpt
new file mode 100644
index 00000000..d35e723c
--- /dev/null
+++ b/tests/Framework/Assert.snapshot.update.phpt
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+use Tester\Assert;
+use Tester\Environment;
+use Tester\Helpers;
+use Tester\Snapshot;
+
+require __DIR__ . '/../bootstrap.php';
+
+// https://bugs.php.net/bug.php?id=76801
+// fixed by https://github.com/php/php-src/pull/3965 in PHP 7.2.18
+if (
+	strncasecmp(PHP_OS, 'win', 3) === 0
+	&& strpos(PHP_BINARY, 'phpdbg') !== false
+	&& version_compare(PHP_VERSION, '7.2.18') < 0
+) {
+	Environment::skip('There is a bug in PHP :(');
+}
+
+putenv(Environment::UPDATE_SNAPSHOTS . '=1');
+Snapshot::$snapshotDir = __DIR__ . DIRECTORY_SEPARATOR . 'snapshots';
+Helpers::purge(Snapshot::$snapshotDir);
+
+// newly created
+
+Assert::false(file_exists(Snapshot::$snapshotDir . DIRECTORY_SEPARATOR . 'Assert.snapshot.update.newSnapshot.phps'));
+Assert::snapshot('newSnapshot', ['answer' => 42]);
+Assert::true(file_exists(Snapshot::$snapshotDir . DIRECTORY_SEPARATOR . 'Assert.snapshot.update.newSnapshot.phps'));
+Assert::contains('42', file_get_contents(Snapshot::$snapshotDir . DIRECTORY_SEPARATOR . 'Assert.snapshot.update.newSnapshot.phps'));
+
+// existing
+
+file_put_contents(
+	Snapshot::$snapshotDir . DIRECTORY_SEPARATOR . 'Assert.snapshot.update.updatedSnapshot.phps',
+	'<?php return array(\'answer\' => 43);' . PHP_EOL
+);
+
+Assert::true(file_exists(Snapshot::$snapshotDir . DIRECTORY_SEPARATOR . 'Assert.snapshot.update.updatedSnapshot.phps'));
+Assert::snapshot('updatedSnapshot', ['answer' => 42]);
+Assert::true(file_exists(Snapshot::$snapshotDir . DIRECTORY_SEPARATOR . 'Assert.snapshot.update.updatedSnapshot.phps'));
+Assert::contains('42', file_get_contents(Snapshot::$snapshotDir . DIRECTORY_SEPARATOR . 'Assert.snapshot.update.updatedSnapshot.phps'));
+
+// Snapshot::$updatedSnapshots
+
+Assert::same([
+	Snapshot::$snapshotDir . DIRECTORY_SEPARATOR . 'Assert.snapshot.update.newSnapshot.phps',
+	Snapshot::$snapshotDir . DIRECTORY_SEPARATOR . 'Assert.snapshot.update.updatedSnapshot.phps',
+], Snapshot::$updatedSnapshots);
+
+// reset the env variable so that the test does not fail due to updated snapshots
+putenv(Environment::UPDATE_SNAPSHOTS . '=0');
diff --git a/tests/Framework/fixtures/Assert.snapshot.anotherSnapshot.phps b/tests/Framework/fixtures/Assert.snapshot.anotherSnapshot.phps
new file mode 100644
index 00000000..a7d8f5b1
--- /dev/null
+++ b/tests/Framework/fixtures/Assert.snapshot.anotherSnapshot.phps
@@ -0,0 +1 @@
+<?php return ['answer' => 42];
diff --git a/tests/Framework/fixtures/Assert.snapshot.existingSnapshot.phps b/tests/Framework/fixtures/Assert.snapshot.existingSnapshot.phps
new file mode 100644
index 00000000..a7d8f5b1
--- /dev/null
+++ b/tests/Framework/fixtures/Assert.snapshot.existingSnapshot.phps
@@ -0,0 +1 @@
+<?php return ['answer' => 42];
diff --git a/tests/Runner/PhpInterpreter.phpt b/tests/Runner/PhpInterpreter.phpt
index 13e89b92..2516d628 100644
--- a/tests/Runner/PhpInterpreter.phpt
+++ b/tests/Runner/PhpInterpreter.phpt
@@ -18,15 +18,15 @@ Assert::same(strpos(PHP_SAPI, 'cgi') !== false, $interpreter->isCgi());
 $count = 0;
 $engines = $interpreter->getCodeCoverageEngines();
 if (defined('PHPDBG_VERSION')) {
-	Assert::contains(Tester\CodeCoverage\Collector::ENGINE_PHPDBG, $engines);
+	Assert::contains([Tester\CodeCoverage\Collector::ENGINE_PHPDBG, PHPDBG_VERSION], $engines);
 	$count++;
 }
 if (extension_loaded('xdebug')) {
-	Assert::contains(Tester\CodeCoverage\Collector::ENGINE_XDEBUG, $engines);
+	Assert::contains([Tester\CodeCoverage\Collector::ENGINE_XDEBUG, phpversion('xdebug')], $engines);
 	$count++;
 }
 if (extension_loaded('pcov')) {
-	Assert::contains(Tester\CodeCoverage\Collector::ENGINE_PCOV, $engines);
+	Assert::contains([Tester\CodeCoverage\Collector::ENGINE_PCOV, phpversion('pcov')], $engines);
 	$count++;
 }
 Assert::count($count, $engines);
diff --git a/tests/Runner/Runner.snapshots.phpt b/tests/Runner/Runner.snapshots.phpt
new file mode 100644
index 00000000..2036cb69
--- /dev/null
+++ b/tests/Runner/Runner.snapshots.phpt
@@ -0,0 +1,80 @@
+<?php
+
+declare(strict_types=1);
+
+use Tester\Assert;
+use Tester\Dumper;
+use Tester\Runner\Test;
+
+require __DIR__ . '/../bootstrap.php';
+require __DIR__ . '/../../src/Runner/OutputHandler.php';
+require __DIR__ . '/../../src/Runner/Test.php';
+require __DIR__ . '/../../src/Runner/TestHandler.php';
+require __DIR__ . '/../../src/Runner/Runner.php';
+
+
+class Logger implements Tester\Runner\OutputHandler
+{
+	public $results = [];
+
+
+	public function prepare(Test $test): void
+	{
+	}
+
+
+	public function finish(Test $test): void
+	{
+		$this->results[basename($test->getFile())] = [$test->getResult(), $test->message];
+	}
+
+
+	public function begin(): void
+	{
+	}
+
+
+	public function end(): void
+	{
+	}
+}
+
+
+Tester\Helpers::purge(__DIR__ . '/snapshots/snapshots');
+
+
+// first run, without update -> fail
+
+$runner = new Tester\Runner\Runner(createInterpreter());
+$runner->paths[] = __DIR__ . '/snapshots/*.phptx';
+$runner->outputHandlers[] = $logger = new Logger;
+$runner->run();
+
+Assert::same(Test::FAILED, $logger->results['update-snapshots.phptx'][0]);
+Assert::match(
+	"Failed: Missing snapshot '%a%', use --update-snapshots option to generate it.\n%A%",
+	trim(Dumper::removeColors($logger->results['update-snapshots.phptx'][1]))
+);
+
+// second run, with update -> fail
+
+$runner = new Tester\Runner\Runner(createInterpreter());
+$runner->paths[] = __DIR__ . '/snapshots/*.phptx';
+$runner->outputHandlers[] = $logger = new Logger;
+$runner->setEnvironmentVariable(Tester\Environment::UPDATE_SNAPSHOTS, '1');
+$runner->run();
+
+Assert::same(Test::FAILED, $logger->results['update-snapshots.phptx'][0]);
+Assert::match(
+	"The following snapshots were updated, please make sure they are correct:\n%a%.snapshot.phps",
+	trim(Dumper::removeColors($logger->results['update-snapshots.phptx'][1]))
+);
+
+// third run, without update -> pass
+
+$runner = new Tester\Runner\Runner(createInterpreter());
+$runner->paths[] = __DIR__ . '/snapshots/*.phptx';
+$runner->outputHandlers[] = $logger = new Logger;
+$runner->run();
+
+Assert::same(Test::PASSED, $logger->results['update-snapshots.phptx'][0]);
diff --git a/tests/Runner/snapshots/.gitignore b/tests/Runner/snapshots/.gitignore
new file mode 100644
index 00000000..373f5f3d
--- /dev/null
+++ b/tests/Runner/snapshots/.gitignore
@@ -0,0 +1 @@
+snapshots/
diff --git a/tests/Runner/snapshots/update-snapshots.phptx b/tests/Runner/snapshots/update-snapshots.phptx
new file mode 100644
index 00000000..0e7a2c2d
--- /dev/null
+++ b/tests/Runner/snapshots/update-snapshots.phptx
@@ -0,0 +1,7 @@
+<?php
+
+use Tester\Assert;
+
+require __DIR__ . '/../../bootstrap.php';
+
+Assert::snapshot('snapshot', 'snapshot value');