test: add snapshot tests for MessageBuilder (#5598)
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
#include "lib/Snapshot.hpp"
|
||||
|
||||
#include "common/Literals.hpp"
|
||||
|
||||
#include <QDebug>
|
||||
#include <QDir>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QStringBuilder>
|
||||
|
||||
namespace {
|
||||
|
||||
using namespace chatterino::literals;
|
||||
|
||||
bool compareJson(const QJsonValue &expected, const QJsonValue &got,
|
||||
const QString &context)
|
||||
{
|
||||
if (expected == got)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (expected.type() != got.type())
|
||||
{
|
||||
qWarning() << context
|
||||
<< "- mismatching type - expected:" << expected.type()
|
||||
<< "got:" << got.type();
|
||||
return false;
|
||||
}
|
||||
switch (expected.type())
|
||||
{
|
||||
case QJsonValue::Array: {
|
||||
auto expArr = expected.toArray();
|
||||
auto gotArr = got.toArray();
|
||||
if (expArr.size() != gotArr.size())
|
||||
{
|
||||
qWarning() << context << "- Mismatching array size - expected:"
|
||||
<< expArr.size() << "got:" << gotArr.size();
|
||||
return false;
|
||||
}
|
||||
for (QJsonArray::size_type i = 0; i < expArr.size(); i++)
|
||||
{
|
||||
if (!compareJson(expArr[i], gotArr[i],
|
||||
context % '[' % QString::number(i) % ']'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
break; // unreachable
|
||||
case QJsonValue::Object: {
|
||||
auto expObj = expected.toObject();
|
||||
auto gotObj = got.toObject();
|
||||
if (expObj.size() != gotObj.size())
|
||||
{
|
||||
qWarning() << context << "- Mismatching object size - expected:"
|
||||
<< expObj.size() << "got:" << gotObj.size();
|
||||
return false;
|
||||
}
|
||||
|
||||
for (auto it = expObj.constBegin(); it != expObj.constEnd(); it++)
|
||||
{
|
||||
if (!gotObj.contains(it.key()))
|
||||
{
|
||||
qWarning() << context << "- Object doesn't contain key"
|
||||
<< it.key();
|
||||
return false;
|
||||
}
|
||||
if (!compareJson(it.value(), gotObj[it.key()],
|
||||
context % '.' % it.key()))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
case QJsonValue::Null:
|
||||
case QJsonValue::Bool:
|
||||
case QJsonValue::Double:
|
||||
case QJsonValue::String:
|
||||
case QJsonValue::Undefined:
|
||||
break;
|
||||
}
|
||||
|
||||
qWarning() << context << "- expected:" << expected << "got:" << got;
|
||||
return false;
|
||||
}
|
||||
|
||||
void mergeJson(QJsonObject &base, const QJsonObject &additional)
|
||||
{
|
||||
for (auto it = additional.begin(); it != additional.end(); it++)
|
||||
{
|
||||
auto ref = base[it.key()];
|
||||
|
||||
if (ref.isArray())
|
||||
{
|
||||
// there's no way of pushing to the array without detaching first
|
||||
auto arr = ref.toArray();
|
||||
if (!it->isArray())
|
||||
{
|
||||
throw std::runtime_error("Mismatched types");
|
||||
}
|
||||
|
||||
// append all additional values
|
||||
auto addArr = it->toArray();
|
||||
for (auto v : addArr)
|
||||
{
|
||||
arr.append(v);
|
||||
}
|
||||
ref = arr;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ref.isObject())
|
||||
{
|
||||
// same here, detach first and overwrite
|
||||
auto obj = ref.toObject();
|
||||
if (!it->isObject())
|
||||
{
|
||||
throw std::runtime_error("Mismatched types");
|
||||
}
|
||||
mergeJson(obj, it->toObject());
|
||||
ref = obj;
|
||||
continue;
|
||||
}
|
||||
|
||||
ref = it.value(); // overwrite for simple types/non-existent keys
|
||||
}
|
||||
}
|
||||
|
||||
QDir baseDir(const QString &category)
|
||||
{
|
||||
QDir snapshotDir(QStringLiteral(__FILE__));
|
||||
snapshotDir.cd("../../../snapshots/");
|
||||
snapshotDir.cd(category);
|
||||
return snapshotDir;
|
||||
}
|
||||
|
||||
QString filePath(const QString &category, const QString &name)
|
||||
{
|
||||
return baseDir(category).filePath(name);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace chatterino::testlib {
|
||||
|
||||
std::unique_ptr<Snapshot> Snapshot::read(QString category, QString name)
|
||||
{
|
||||
if (!name.endsWith(u".json"))
|
||||
{
|
||||
name.append(u".json");
|
||||
}
|
||||
|
||||
QFile file(filePath(category, name));
|
||||
if (!file.open(QFile::ReadOnly))
|
||||
{
|
||||
throw std::runtime_error("Failed to open file");
|
||||
}
|
||||
auto content = file.readAll();
|
||||
file.close();
|
||||
const auto doc = QJsonDocument::fromJson(content).object();
|
||||
|
||||
return std::unique_ptr<Snapshot>(
|
||||
new Snapshot(std::move(category), std::move(name), doc));
|
||||
}
|
||||
|
||||
QStringList Snapshot::discover(const QString &category)
|
||||
{
|
||||
auto files =
|
||||
baseDir(category).entryList(QDir::NoDotAndDotDot | QDir::Files);
|
||||
for (auto &file : files)
|
||||
{
|
||||
file.remove(".json");
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
bool Snapshot::run(const QJsonValue &got, bool updateSnapshots) const
|
||||
{
|
||||
if (updateSnapshots)
|
||||
{
|
||||
this->write(got);
|
||||
return true;
|
||||
}
|
||||
|
||||
return compareJson(this->output_, got, QStringLiteral("output"));
|
||||
}
|
||||
|
||||
Snapshot::Snapshot(QString category, QString name, const QJsonObject &root)
|
||||
: category_(std::move(category))
|
||||
, name_(std::move(name))
|
||||
, input_(root["input"_L1])
|
||||
, params_(root["params"_L1].toObject())
|
||||
, settings_(root["settings"_L1].toObject())
|
||||
, output_(root["output"_L1])
|
||||
{
|
||||
}
|
||||
|
||||
void Snapshot::write(const QJsonValue &got) const
|
||||
{
|
||||
QFile file(filePath(this->category_, this->name_));
|
||||
if (!file.open(QFile::WriteOnly))
|
||||
{
|
||||
throw std::runtime_error("Failed to open file");
|
||||
}
|
||||
|
||||
QJsonObject obj{
|
||||
{"input"_L1, this->input_},
|
||||
{"output"_L1, got},
|
||||
};
|
||||
if (!this->params_.isEmpty())
|
||||
{
|
||||
obj.insert("params"_L1, this->params_);
|
||||
}
|
||||
if (!this->settings_.isEmpty())
|
||||
{
|
||||
obj.insert("settings"_L1, this->settings_);
|
||||
}
|
||||
|
||||
file.write(QJsonDocument{obj}.toJson());
|
||||
file.close();
|
||||
}
|
||||
|
||||
QByteArray Snapshot::mergedSettings(const QByteArray &base) const
|
||||
{
|
||||
auto baseDoc = QJsonDocument::fromJson(base);
|
||||
if (!baseDoc.isObject())
|
||||
{
|
||||
throw std::runtime_error("Invalid base settings");
|
||||
}
|
||||
auto baseObj = baseDoc.object();
|
||||
mergeJson(baseObj, this->settings_);
|
||||
|
||||
baseDoc.setObject(baseObj);
|
||||
return baseDoc.toJson(QJsonDocument::Compact);
|
||||
}
|
||||
|
||||
} // namespace chatterino::testlib
|
||||
@@ -0,0 +1,135 @@
|
||||
#pragma once
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QJsonValue>
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
|
||||
#include <memory>
|
||||
|
||||
namespace chatterino::testlib {
|
||||
|
||||
/// @brief JSON based snapshot/approval tests
|
||||
///
|
||||
/// Snapshot tests record the output of some computation based on some @a input.
|
||||
/// Additionally, users can provide @a params. There isn't any rule on what goes
|
||||
/// into @a input vs. @a params - a rule of thumb is to put everything that's
|
||||
/// not directly an input to the target function into @a params (like settings).
|
||||
/// Similarly, settings can be specified in "settings". These can be merged with
|
||||
/// existing settings (the base) in mergedSettings().
|
||||
///
|
||||
/// Snapshots are stored in `tests/snapshots/{category}/{name}.json`.
|
||||
/// `category` can consist of multiple directories (e.g. `foo/bar`).
|
||||
///
|
||||
/// Note that when using CTest, added snapshots are only discovered when
|
||||
/// reloading the tests.
|
||||
///
|
||||
/// @par A minimal example
|
||||
///
|
||||
/// ```cpp
|
||||
/// #include "lib/Snapshot.hpp"
|
||||
/// #include "Test.hpp"
|
||||
///
|
||||
/// #include <array>
|
||||
/// #include <QStringBuilder>
|
||||
///
|
||||
/// namespace testlib = chatterino::testlib;
|
||||
///
|
||||
/// constexpr bool UPDATE_SNAPSHOTS = false;
|
||||
///
|
||||
/// class ExampleTest : public ::testing::TestWithParam<QString> {};
|
||||
///
|
||||
/// TEST_P(ExampleTest, Run) {
|
||||
/// auto fixture = testlib::Snapshot::read("category", GetParam());
|
||||
/// auto output = functionToTest(fixture.input()); // or input{String,Utf8}
|
||||
/// // if snapshots are supposed to be updated, this will write the output
|
||||
/// ASSERT_TRUE(fixture.run(output, UPDATE_SNAPSHOTS));
|
||||
/// }
|
||||
///
|
||||
/// INSTANTIATE_TEST_SUITE_P(ExampleInstance, ExampleTest,
|
||||
/// testing::ValuesIn(testlib::Snapshot::discover("category")));
|
||||
///
|
||||
/// // verify that all snapshots are included
|
||||
/// TEST(ExampleTest, Integrity) {
|
||||
/// ASSERT_FALSE(UPDATE_SNAPSHOTS); // make sure fixtures are actually tested
|
||||
/// }
|
||||
/// ```
|
||||
class Snapshot
|
||||
{
|
||||
public:
|
||||
Snapshot(const Snapshot &) = delete;
|
||||
Snapshot &operator=(const Snapshot &) = delete;
|
||||
|
||||
Snapshot(Snapshot &&) = default;
|
||||
Snapshot &operator=(Snapshot &&) = default;
|
||||
~Snapshot() = default;
|
||||
|
||||
/// Read a snapshot
|
||||
static std::unique_ptr<Snapshot> read(QString category, QString name);
|
||||
|
||||
/// Finds all tests in @a category
|
||||
static QStringList discover(const QString &category);
|
||||
|
||||
/// @brief Runs the snapshot test
|
||||
///
|
||||
/// If @a updateSnapshots is `false`, this checks that @a got matches the
|
||||
/// expected output (#output()).
|
||||
/// If @a updateSnapshots is `true`, this sets @a got as the expected
|
||||
/// output of this snapshot.
|
||||
bool run(const QJsonValue &got, bool updateSnapshots) const;
|
||||
|
||||
QString name() const
|
||||
{
|
||||
return this->name_;
|
||||
}
|
||||
|
||||
QString category() const
|
||||
{
|
||||
return this->category_;
|
||||
}
|
||||
|
||||
QJsonValue input() const
|
||||
{
|
||||
return this->input_;
|
||||
}
|
||||
|
||||
QString inputString() const
|
||||
{
|
||||
return this->input_.toString();
|
||||
}
|
||||
|
||||
QByteArray inputUtf8() const
|
||||
{
|
||||
return this->input_.toString().toUtf8();
|
||||
}
|
||||
|
||||
QJsonValue param(QLatin1String name) const
|
||||
{
|
||||
return this->params_[name];
|
||||
}
|
||||
QJsonValue param(const char *name) const
|
||||
{
|
||||
return this->param(QLatin1String{name});
|
||||
}
|
||||
|
||||
QByteArray mergedSettings(const QByteArray &base) const;
|
||||
|
||||
QJsonValue output() const
|
||||
{
|
||||
return this->output_;
|
||||
}
|
||||
|
||||
private:
|
||||
Snapshot(QString category, QString name, const QJsonObject &root);
|
||||
|
||||
void write(const QJsonValue &got) const;
|
||||
|
||||
QString category_;
|
||||
QString name_;
|
||||
QJsonValue input_;
|
||||
QJsonObject params_;
|
||||
QJsonObject settings_;
|
||||
QJsonValue output_;
|
||||
};
|
||||
|
||||
} // namespace chatterino::testlib
|
||||
Reference in New Issue
Block a user