Skip to content

Commit

Permalink
Add unittest framework and some dependency tests
Browse files Browse the repository at this point in the history
Dub has always been notoriously hard to test due to the amount of IO it does.
Over the past few years, I have progressively refactored it to allow
dependency injection to take place, which this finally put into action.
By overriding the `PackageManager`, `PackageSupplier`, and a few strategic
functions, we can start to unittest Dub's behavior solely in unittests.
This should hopefully tremendously helps with adding regression tests
for the package manager side of Dub (the build side still need work
to have similar capabilities).
  • Loading branch information
Geod24 authored and thewilsonator committed Dec 27, 2023
1 parent 94cb94c commit b044acc
Show file tree
Hide file tree
Showing 4 changed files with 310 additions and 9 deletions.
25 changes: 20 additions & 5 deletions source/dub/packagemanager.d
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ class PackageManager {
* Returns:
* A `Package` if one was found, `null` if none exists.
*/
private Package lookup (string name, Version vers) {
protected Package lookup (string name, Version vers) {
if (!this.m_initialized)
this.refresh();

Expand Down Expand Up @@ -243,6 +243,21 @@ class PackageManager {
return this.lookup(name, ver);
}

/**
* Adds a `Package` to this `PackageManager`
*
* This is currently only available in unittests as it is a convenience
* function used by `TestDub`, but could be generalized once IO has been
* abstracted away from this class.
*/
version (unittest) Package add(Package pkg)
{
// See `PackageManager.addPackages` for inspiration.
assert(!pkg.subPackages.length, "Subpackages are not yet supported");
this.m_internal.fromPath ~= pkg;
return pkg;
}

/// ditto
deprecated("Use the overload that accepts a `Version` as second argument")
Package getPackage(string name, string ver, bool enable_overrides = true)
Expand Down Expand Up @@ -1121,7 +1136,7 @@ private enum LocalOverridesFilename = "local-overrides.json";
* Additionally, each location host a config file,
* which is not managed by this module, but by dub itself.
*/
private struct Location {
package struct Location {
/// The absolute path to the root of the location
NativePath packagePath;

Expand Down Expand Up @@ -1364,7 +1379,7 @@ private struct Location {
* Returns:
* A `Package` if one was found, `null` if none exists.
*/
private inout(Package) lookup(string name, Version ver, PackageManager mgr) inout {
inout(Package) lookup(string name, Version ver, PackageManager mgr) inout {
foreach (pkg; this.localPackages)
if (pkg.name == name && pkg.version_.matches(ver, VersionMatchMode.standard))
return pkg;
Expand All @@ -1391,7 +1406,7 @@ private struct Location {
* Returns:
* A `Package` if one was found, `null` if none exists.
*/
private Package load (string name, Version vers, PackageManager mgr)
Package load (string name, Version vers, PackageManager mgr)
{
if (auto pkg = this.lookup(name, vers, mgr))
return pkg;
Expand Down Expand Up @@ -1423,7 +1438,7 @@ private struct Location {
* but this function returns `$BASE/$NAME-$VERSION/`
* `$BASE` is `this.packagePath`.
*/
private NativePath getPackagePath (string name, string vers)
NativePath getPackagePath (string name, string vers)
{
NativePath result = this.packagePath ~ name ~ vers;
result.endsWithSlash = true;
Expand Down
4 changes: 2 additions & 2 deletions source/dub/project.d
Original file line number Diff line number Diff line change
Expand Up @@ -1734,8 +1734,8 @@ unittest
This is the runtime representation of the information contained in
"dub.selections.json" within a package's directory.
*/
final class SelectedVersions {
private {
public class SelectedVersions {
protected {
enum FileVersion = 1;
Selected m_selections;
bool m_dirty = false; // has changes since last save
Expand Down
157 changes: 155 additions & 2 deletions source/dub/test/base.d
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ public import std.algorithm;

import dub.data.settings;
public import dub.dependency;
import dub.dub;
import dub.package_;
public import dub.dub;
public import dub.package_;
import dub.packagemanager;
import dub.packagesuppliers.packagesupplier;
import dub.project;

// TODO: Remove and handle logging the same way we handle other IO
import dub.internal.logging;
Expand Down Expand Up @@ -54,11 +55,163 @@ public class TestDub : Dub
return Settings.init;
}

///
protected override PackageManager makePackageManager() const
{
return new TestPackageManager();
}

/// See `MockPackageSupplier` documentation for this class' implementation
protected override PackageSupplier makePackageSupplier(string url) const
{
return new MockPackageSupplier(url);
}

/// Loads the package from the specified path as the main project package.
public override void loadPackage(NativePath path)
{
assert(0, "Not implemented");
}

/// Loads a specific package as the main project package (can be a sub package)
public override void loadPackage(Package pack)
{
m_project = new Project(m_packageManager, pack, new TestSelectedVersions());
}

/// Reintroduce parent overloads
public alias loadPackage = Dub.loadPackage;

/**
* Creates a package with the provided recipe
*
* This is a convenience function provided to create a package based on
* a given recipe. This is to allow test-cases to be written based off
* issues more easily.
*
* In order for the `Package` to be visible to `Dub`, use `addTestPackage`,
* as `makeTestPackage` simply creates the `Package` without adding it.
*
* Params:
* str = The string representation of the `PackageRecipe`
* recipe = The `PackageRecipe` to use
* vers = The version the package is at, e.g. `Version("1.0.0")`
* fmt = The format `str` is in, either JSON or SDL
*
* Returns:
* The created `Package` instance
*/
public Package makeTestPackage(string str, Version vers, PackageFormat fmt = PackageFormat.json)
{
import dub.recipe.io;
final switch (fmt) {
case PackageFormat.json:
auto recipe = parsePackageRecipe(str, "dub.json");
recipe.version_ = vers.toString();
return new Package(recipe);
case PackageFormat.sdl:
auto recipe = parsePackageRecipe(str, "dub.sdl");
recipe.version_ = vers.toString();
return new Package(recipe);
}
}

/// Ditto
public Package addTestPackage(string str, Version vers, PackageFormat fmt = PackageFormat.json)
{
return this.packageManager.add(this.makeTestPackage(str, vers, fmt));
}
}

/**
*
*/
public class TestSelectedVersions : SelectedVersions {
import dub.recipe.selection;

/// Forward to parent's constructor
public this(uint version_ = FileVersion) @safe pure nothrow @nogc
{
super(version_);
}

/// Ditto
public this(Selected data) @safe pure nothrow @nogc
{
super(data);
}

/// Do not do IO
public override void save(NativePath path)
{
// No-op
}
}

/**
* A `PackageManager` suitable to be used in unittests
*
* This `PackageManager` does not perform any IO. It imitates the base
* `PackageManager`, exposing 3 locations, but loading of packages is not
* automatic and needs to be done by passing a `Package` instance.
*/
package class TestPackageManager : PackageManager
{
this()
{
NativePath pkg = NativePath("/tmp/dub-testsuite-nonexistant/packages/");
NativePath user = NativePath("/tmp/dub-testsuite-nonexistant/user/");
NativePath system = NativePath("/tmp/dub-testsuite-nonexistant/system/");
super(pkg, user, system, false);
}

/// Disabled as semantic are not implementable unless a virtual FS is created
public override @property void customCachePaths(NativePath[] custom_cache_paths)
{
assert(0, "Function not implemented");
}

/// Ditto
public override Package store(NativePath src, PlacementLocation dest, string name, Version vers)
{
assert(0, "Function not implemented");
}

/**
* This function usually scans the filesystem for packages.
*
* We don't want to do IO access and rely on users adding the packages
* before the test starts instead.
*
* Note: Deprecated `refresh(bool)` does IO, but it's deprecated
*/
public override void refresh()
{
// Do nothing
}

/**
* Looks up a specific package
*
* Unlike its parent class, no lazy loading is performed.
* Additionally, as they are already deprecated, overrides are
* disabled and not available.
*/
public override Package getPackage(string name, Version vers, bool enable_overrides = false)
{
//assert(!enable_overrides, "Overrides are not implemented for TestPackageManager");

// Implementation inspired from `PackageManager.lookup`,
// except we replaced `load` with `lookup`.
if (auto pkg = this.m_internal.lookup(name, vers, this))
return pkg;

foreach (ref location; this.m_repositories)
if (auto p = location.lookup(name, vers, this))
return p;

return null;
}
}

/**
Expand Down
133 changes: 133 additions & 0 deletions source/dub/test/dependencies.d
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/*******************************************************************************
Test for dependencies
This module is mostly concerned with dependency resolutions and visible user
behavior. Tests that check how different recipe would interact with one
another, and how conflicts are resolved or reported, belong here.
The project (the loaded package) is usually named 'a' and dependencies use
single-letter, increasing name, for simplicity. Version 1.0.0 is used where
versions do not matter. Packages are usually created in reverse dependency
order when possible, unless the creation order matters.
Test that deal with dependency resolution should not concern themselves with
the registry: instead, packages are added to the `PackageManager`, as that
makes testing the core logic more robust without adding a layer
of complexity brought by the `PackageSupplier`.
Most tests have 3 parts: First, setup the various packages. Then, run
`dub.upgrade(UpgradeOptions.select)` to create the selection. Finally,
run tests on the resulting state.
*******************************************************************************/

module dub.test.dependencies;

version (unittest):

import dub.test.base;

// Ensure that simple dependencies get resolved correctly
unittest
{
const a = `name "a"
dependency "b" version="*"
dependency "c" version="*"
`;
const b = `name "b"`;
const c = `name "c"`;

scope dub = new TestDub();
dub.addTestPackage(c, Version("1.0.0"), PackageFormat.sdl);
dub.addTestPackage(b, Version("1.0.0"), PackageFormat.sdl);
dub.loadPackage(dub.addTestPackage(a, Version("1.0.0"), PackageFormat.sdl));

dub.upgrade(UpgradeOptions.select);

assert(dub.project.hasAllDependencies(), "project has missing dependencies");
assert(dub.project.getDependency("b", true), "Missing 'b' dependency");
assert(dub.project.getDependency("c", true), "Missing 'c' dependency");
assert(dub.project.getDependency("no", true) is null, "Returned unexpected dependency");
}

// Test that indirect dependencies get resolved correctly
unittest
{
const a = `name "a"
dependency "b" version="*"
`;
const b = `name "b"
dependency "c" version="*"
`;
const c = `name "c"`;

scope dub = new TestDub();
dub.addTestPackage(c, Version("1.0.0"), PackageFormat.sdl);
dub.addTestPackage(b, Version("1.0.0"), PackageFormat.sdl);
dub.loadPackage(dub.addTestPackage(a, Version("1.0.0"), PackageFormat.sdl));

dub.upgrade(UpgradeOptions.select);

assert(dub.project.hasAllDependencies(), "project has missing dependencies");
assert(dub.project.getDependency("b", true), "Missing 'b' dependency");
assert(dub.project.getDependency("c", true), "Missing 'c' dependency");
assert(dub.project.getDependency("no", true) is null, "Returned unexpected dependency");
}

// Simple diamond dependency
unittest
{
const a = `name "a"
dependency "b" version="*"
dependency "c" version="*"
`;
const b = `name "b"
dependency "d" version="*"
`;
const c = `name "c"
dependency "d" version="*"
`;
const d = `name "d"`;

scope dub = new TestDub();
dub.addTestPackage(d, Version("1.0.0"), PackageFormat.sdl);
dub.addTestPackage(c, Version("1.0.0"), PackageFormat.sdl);
dub.addTestPackage(b, Version("1.0.0"), PackageFormat.sdl);
dub.loadPackage(dub.addTestPackage(a, Version("1.0.0"), PackageFormat.sdl));

dub.upgrade(UpgradeOptions.select);

assert(dub.project.hasAllDependencies(), "project has missing dependencies");
assert(dub.project.getDependency("b", true), "Missing 'b' dependency");
assert(dub.project.getDependency("c", true), "Missing 'c' dependency");
assert(dub.project.getDependency("c", true), "Missing 'd' dependency");
assert(dub.project.getDependency("no", true) is null, "Returned unexpected dependency");
}

// Missing dependencies trigger an error
unittest
{
const a = `name "a"
dependency "b" version="*"
`;

scope dub = new TestDub();
dub.loadPackage(dub.addTestPackage(a, Version("1.0.0"), PackageFormat.sdl));

try
dub.upgrade(UpgradeOptions.select);
catch (Exception exc)
assert(exc.message() == `Failed to find any versions for package b, referenced by a 1.0.0`);

assert(!dub.project.hasAllDependencies(), "project should have missing dependencies");
assert(dub.project.getDependency("b", true) is null, "Found 'b' dependency");
assert(dub.project.getDependency("no", true) is null, "Returned unexpected dependency");

// Add the missing dependency to our PackageManager
dub.addTestPackage(`name "b"`, Version("1.0.0"), PackageFormat.sdl);
dub.upgrade(UpgradeOptions.select);
assert(dub.project.hasAllDependencies(), "project have missing dependencies");
assert(dub.project.getDependency("b", true), "Missing 'b' dependency");
assert(dub.project.getDependency("no", true) is null, "Returned unexpected dependency");
}

0 comments on commit b044acc

Please sign in to comment.