Repository: EmbarkStudios/cargo-about (language: rust)

Issue:
Add tests
We don't have any tests at all for this crate right now, which is not great as makes it more difficult to take in contributions and to make sure the crate works when we update dependencies or does changes to it.

Think a good start would be to add a small integration test where we run cargo about on a small checked-in test crate that has 1 dependency with 2 different licenses we pick and with a specific template to verify the output is the same (which we can check in).

And would always be good with unit tests for specific features and functionality, more scalable than having integration tests. 

Would be hard to manually find and detect if bugs creep into this and we start missing licenses and attribution for certain crates, hence having tests will be extra important.


Discussion / hints:
I'd be happy to try this, and can (feel like I _should_) contribute in the following stages:

- A [bats](https://github.com/bats-core/bats-core)-based end-to-end test that verifies the behaviour at the outermost level.
- A series of unit tests that verify module boundaries. The place to start seems like `src/licenses/mod.rs`.

Would this contribution be helpful? How do you feel about this approach?
Cool that you want to take a look at this!

I would say we wouldn't want the `bats` option at all, if you wanted to add "UX" tests you could do something like what is suggested [here](https://rust-cli.github.io/book/tutorial/testing.html#testing-cli-applications-by-running-them)

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index 98539a5..cbdb3f2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -51,6 +51,34 @@ dependencies = [
  "zstd",
 ]
 
+[[package]]
+name = "assert_cmd"
+version = "2.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93ae1ddd39efd67689deb1979d80bad3bf7f2b09c6e6117c8d1f2443b5e2f83e"
+dependencies = [
+ "bstr",
+ "doc-comment",
+ "predicates",
+ "predicates-core",
+ "predicates-tree",
+ "wait-timeout",
+]
+
+[[package]]
+name = "assert_fs"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf09bb72e00da477c2596865e8873227e2196d263cca35414048875dbbeea1be"
+dependencies = [
+ "doc-comment",
+ "globwalk",
+ "predicates",
+ "predicates-core",
+ "predicates-tree",
+ "tempfile",
+]
+
 [[package]]
 name = "atty"
 version = "0.2.14"
@@ -107,7 +135,9 @@ version = "0.2.17"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ba3569f383e8f1598449f1a423e72e99569137b47740b1da11ef19af3d5c3223"
 dependencies = [
+ "lazy_static",
  "memchr",
+ "regex-automata",
 ]
 
 [[package]]
@@ -150,6 +180,8 @@ dependencies = [
  "ansi_term",
  "anyhow",
  "askalono",
+ "assert_cmd",
+ "assert_fs",
  "atty",
  "cd",
  "clap",
@@ -163,6 +195,7 @@ dependencies = [
  "log",
  "mimalloc",
  "parking_lot",
+ "predicates",
  "rayon",
  "reqwest",
  "ring",
@@ -353,6 +386,12 @@ dependencies = [
  "lazy_static",
 ]
 
+[[package]]
+name = "difflib"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
+
 [[package]]
 name = "digest"
 version = "0.8.1"
@@ -362,6 +401,12 @@ dependencies = [
  "generic-array",
 ]
 
+[[package]]
+name = "doc-comment"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10"
+
 [[package]]
 name = "either"
 version = "1.6.1"
@@ -383,6 +428,15 @@ version = "0.1.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e88a8acf291dafb59c2d96e8f59828f3838bb1a70398823ade51a84de6a6deed"
 
+[[package]]
+name = "fastrand"
+version = "1.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3fcf0cee53519c866c09b5de1f6c56ff9d647101f81c1964fa632e148896cdf"
+dependencies = [
+ "instant",
+]
+
 [[package]]
 name = "fern"
 version = "0.6.0"
@@ -410,6 +464,15 @@ dependencies = [
  "miniz_oxide",
 ]
 
+[[package]]
+name = "float-cmp"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
+dependencies = [
+ "num-traits",
+]
+
 [[package]]
 name = "fnv"
 version = "1.0.7"
@@ -507,6 +570,17 @@ dependencies = [
  "regex",
 ]
 
+[[package]]
+name = "globwalk"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc"
+dependencies = [
+ "bitflags",
+ "ignore",
+ "walkdir",
+]
+
 [[package]]
 name = "h2"
 version = "0.3.13"
@@ -681,6 +755,15 @@ dependencies = [
  "hashbrown",
 ]
 
+[[package]]
+name = "instant"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c"
+dependencies = [
+ "cfg-if",
+]
+
 [[package]]
 name = "ipnet"
 version = "2.4.0"
@@ -847,6 +930,12 @@ dependencies = [
  "winapi",
 ]
 
+[[package]]
+name = "normalize-line-endings"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be"
+
 [[package]]
 name = "ntapi"
 version = "0.3.7"
@@ -1005,6 +1094,36 @@ version = "0.2.16"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872"
 
+[[package]]
+name = "predicates"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5aab5be6e4732b473071984b3164dbbfb7a3674d30ea5ff44410b6bcd960c3c"
+dependencies = [
+ "difflib",
+ "float-cmp",
+ "itertools",
+ "normalize-line-endings",
+ "predicates-core",
+ "regex",
+]
+
+[[package]]
+name = "predicates-core"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da1c2388b1513e1b605fcec39a95e0a9e8ef088f71443ef37099fa9ae6673fcb"
+
+[[package]]
+name = "predicates-tree"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d86de6de25020a36c6d3643a86d9a6a9f552107c0559c60ea03551b5e16c032"
+dependencies = [
+ "predicates-core",
+ "termtree",
+]
+
 [[package]]
 name = "proc-macro-error"
 version = "1.0.4"
@@ -1128,12 +1247,27 @@ dependencies = [
  "regex-syntax",
 ]
 
+[[package]]
+name = "regex-automata"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
+
 [[package]]
 name = "regex-syntax"
 version = "0.6.25"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b"
 
+[[package]]
+name = "remove_dir_all"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7"
+dependencies = [
+ "winapi",
+]
+
 [[package]]
 name = "reqwest"
 version = "0.11.10"
@@ -1384,6 +1518,20 @@ dependencies = [
  "unicode-xid",
 ]
 
+[[package]]
+name = "tempfile"
+version = "3.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4"
+dependencies = [
+ "cfg-if",
+ "fastrand",
+ "libc",
+ "redox_syscall",
+ "remove_dir_all",
+ "winapi",
+]
+
 [[package]]
 name = "termcolor"
 version = "1.1.3"
@@ -1393,6 +1541,12 @@ dependencies = [
  "winapi-util",
 ]
 
+[[package]]
+name = "termtree"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "507e9898683b6c43a9aa55b64259b721b52ba226e0f3779137e50ad114a4c90b"
+
 [[package]]
 name = "textwrap"
 version = "0.15.0"
@@ -1632,6 +1786,15 @@ version = "0.9.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
 
+[[package]]
+name = "wait-timeout"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "walkdir"
 version = "2.3.2"
diff --git a/Cargo.toml b/Cargo.toml
index d571b14..4156c6b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -80,3 +80,11 @@ toml_edit = "0.14"
 twox-hash = "1.6"
 # Url parsing
 url = "2.2"
+
+[dev-dependencies]
+# Filesystems - Filesystem fixtures and assertions for testing
+assert_fs = "1.0.7"
+# Easy command initialization and assertions
+assert_cmd = "2.0"
+# Composable first-order predicate functions
+predicates = "2.1"


Test patch:
diff --git a/tests/all.rs b/tests/all.rs
new file mode 100644
index 0000000..17753d3
--- /dev/null
+++ b/tests/all.rs
@@ -0,0 +1,2 @@
+mod cli;
+mod utils;
diff --git a/tests/cli/generate.rs b/tests/cli/generate.rs
new file mode 100644
index 0000000..4cedede
--- /dev/null
+++ b/tests/cli/generate.rs
@@ -0,0 +1,446 @@
+use crate::utils::*;
+
+use anyhow::Result;
+use predicates::prelude::*;
+
+#[test]
+fn fails_when_templates_arg_missing() -> Result<()> {
+    let package = Package::builder().build()?;
+
+    CargoAbout::new(&package)?
+        .generate()
+        .assert()
+        .failure()
+        .stderr(predicate::str::is_match(
+            r"required arguments were not provided:\s*<TEMPLATES>",
+        )?);
+
+    Ok(())
+}
+
+#[test]
+fn fails_when_manifest_absent() -> Result<()> {
+    let package = Package::builder().no_manifest().build()?;
+
+    CargoAbout::new(&package)?
+        .generate()
+        .template(package.template()?)
+        .assert()
+        .failure()
+        .stderr(predicate::str::is_match(
+            r"cargo manifest path '.*' does not exist",
+        )?);
+
+    Ok(())
+}
+
+#[test]
+fn fails_when_manifest_invalid() -> Result<()> {
+    let package = Package::builder().file("Cargo.toml", "").build()?;
+
+    CargoAbout::new(&package)?
+        .generate()
+        .template(package.template()?)
+        .assert()
+        .failure()
+        .stderr(predicate::str::contains("failed to parse manifest"));
+
+    Ok(())
+}
+
+#[test]
+fn fails_back_to_default_about_config_when_absent() -> Result<()> {
+    let package = Package::builder().no_about_config().build()?;
+
+    CargoAbout::new(&package)?
+        .generate()
+        .template(package.template()?)
+        .assert()
+        .stderr(predicate::str::contains(
+            "no 'about.toml' found, falling back to default configuration",
+        ));
+
+    Ok(())
+}
+
+#[test]
+fn fails_when_template_file_missing() -> Result<()> {
+    let package = Package::builder().no_template().build()?;
+
+    CargoAbout::new(&package)?
+        .generate()
+        .template("non-existent-about.hbs")
+        .assert()
+        .failure()
+        .stderr(predicate::str::contains(
+            "template(s) path non-existent-about.hbs does not exist",
+        ));
+
+    Ok(())
+}
+
+#[test]
+fn reports_no_licenses_when_no_licenses() -> Result<()> {
+    let package = Package::builder().build()?;
+
+    CargoAbout::new(&package)?
+        .generate()
+        .template(package.template()?)
+        .assert()
+        .success()
+        .stderr(unable_to_synthesize_license_expr_warning(&package))
+        .stdout(overview_count(0))
+        .stdout(licenses_count(0));
+
+    Ok(())
+}
+
+#[test]
+fn fails_when_missing_accepted_field() -> Result<()> {
+    let package = Package::builder().file("about.toml", "").build()?;
+
+    CargoAbout::new(&package)?
+        .generate()
+        .template(package.template()?)
+        .assert()
+        .failure()
+        .stderr(predicates::str::contains("missing field `accepted`"));
+
+    Ok(())
+}
+
+#[test]
+fn reports_no_licenses_when_no_license_and_accepted_field_empty() -> Result<()> {
+    let package = Package::builder().build()?;
+
+    CargoAbout::new(&package)?
+        .generate()
+        .template(package.template()?)
+        .assert()
+        .success()
+        .stderr(unable_to_synthesize_license_expr_warning(&package))
+        .stdout(overview_count(0))
+        .stdout(licenses_count(0));
+
+    Ok(())
+}
+
+#[test]
+fn fails_when_license_field_valid_and_accepted_field_empty() -> Result<()> {
+    let package = Package::builder().license(Some("MIT")).build()?;
+
+    CargoAbout::new(&package)?
+        .generate()
+        .template(package.template()?)
+        .assert()
+        .failure()
+        .stderr(predicates::str::contains(
+            "failed to satisfy license requirements",
+        ))
+        .stdout("");
+
+    Ok(())
+}
+
+#[test]
+fn reports_no_licenses_when_license_field_unknown() -> Result<()> {
+    let package = Package::builder()
+        .license(Some("MIT"))
+        .accepted(&["MIT"])
+        .license(Some("UNKNOWN"))
+        .build()?;
+
+    CargoAbout::new(&package)?
+        .generate()
+        .template(package.template()?)
+        .assert()
+        .success()
+        .stderr(predicates::str::contains(
+            "unable to parse license expression for 'package 0.0.0': UNKNOWN",
+        ))
+        .stdout(overview_count(0))
+        .stdout(licenses_count(0));
+
+    Ok(())
+}
+
+#[test]
+fn reports_a_license_when_license_field_valid() -> Result<()> {
+    let package = Package::builder()
+        .license(Some("MIT"))
+        .accepted(&["MIT"])
+        .build()?;
+
+    CargoAbout::new(&package)?
+        .generate()
+        .template(package.template()?)
+        .assert()
+        .success()
+        .stderr("")
+        .stdout(overview_count(1))
+        .stdout(licenses_count(1))
+        .stdout(contains_default_mit_license_content());
+
+    Ok(())
+}
+
+// TODO: might be nice to let the user know that there was a license file field, but
+// that the file was missing.
+#[test]
+fn reports_no_licenses_when_license_file_field_but_no_file() -> Result<()> {
+    let package = Package::builder()
+        .license_file("LICENSE", None)
+        .accepted(&["MIT"])
+        .build()?;
+
+    CargoAbout::new(&package)?
+        .generate()
+        .template(package.template()?)
+        .assert()
+        .success()
+        .stderr(unable_to_synthesize_license_expr_warning(&package))
+        .stdout(overview_count(0))
+        .stdout(licenses_count(0));
+
+    Ok(())
+}
+
+// TODO: might be nice to let the user know that there was a license file field, but
+// that the file was empty / unrecognizable.
+#[test]
+fn reports_no_licenses_when_license_file_field_but_file_empty() -> Result<()> {
+    let package = Package::builder()
+        .license_file("LICENSE", Some(""))
+        .accepted(&["MIT"])
+        .build()?;
+
+    CargoAbout::new(&package)?
+        .generate()
+        .template(package.template()?)
+        .assert()
+        .success()
+        .stderr(unable_to_synthesize_license_expr_warning(&package))
+        .stdout(overview_count(0))
+        .stdout(licenses_count(0));
+
+    Ok(())
+}
+
+// TODO: This seems like incorrect behavior.... IMO the report should be generated
+// and maybe custom and/or non-accepted licenses should be included with some
+// additional metadata noting that it is not accepted..
+#[test]
+fn reports_no_licenses_when_manifest_has_license_file_field_with_non_spdx_text() -> Result<()> {
+    let package = Package::builder()
+        .license_file(
+            "LICENSE",
+            Some("Copyright (c) 2022 Big Birdz. No permissions granted ever."),
+        )
+        .build()?;
+
+    CargoAbout::new(&package)?
+        .generate()
+        .template(package.template()?)
+        .assert()
+        .success()
+        .stderr(unable_to_synthesize_license_expr_warning(&package))
+        .stdout(overview_count(0))
+        .stdout(licenses_count(0));
+
+    Ok(())
+}
+
+#[test]
+fn reports_custom_spdx_license_text_when_manifest_has_license_file_field_with_spdx_text(
+) -> Result<()> {
+    let license_text = mit_license_text("2022", "Big Birdz");
+
+    let package = Package::builder()
+        .license_file("LICENSE", Some(&license_text))
+        .accepted(&["MIT"])
+        .build()?;
+
+    CargoAbout::new(&package)?
+        .generate()
+        .template(package.template()?)
+        .assert()
+        .success()
+        // TODO: There should not be a warning about a missing licenses field
+        // since the manifest does have a license file field and according to the
+        // cargo docs, a manifest should have a license field or a license file
+        // field but not both.
+        .stderr(contains_missing_license_field_warning(&package))
+        .stdout(overview_count(1))
+        .stdout(licenses_count(1))
+        .stdout(predicates::str::contains(&license_text));
+
+    Ok(())
+}
+
+#[test]
+fn reports_no_licenses_when_manifest_has_license_file_field_with_spdx_license_text_and_non_std_filename(
+) -> Result<()> {
+    let license_text = mit_license_text("2022", "Big Birdz");
+
+    let package = Package::builder()
+        .license_file("NON_STD_LICENSE_FILENAME", Some(&license_text))
+        .accepted(&["MIT"])
+        .build()?;
+
+    CargoAbout::new(&package)?
+        .generate()
+        .template(package.template()?)
+        .assert()
+        .success()
+        // TODO: There should not be a warning about a missing licenses field
+        // since the manifest does have a license file field and according to the
+        // cargo docs, a manifest should have a license field or a license file
+        // field but not both.
+        .stderr(contains_missing_license_field_warning(&package))
+        // TODO: I would've expected this test case to succeed given that the
+        // name of the license file is given in the manifest and it is clear
+        // that the license can be detected from its text (which works when
+        // the license file is named `LICENSE`).
+        // I suppose I can understand if scanning all files in the repo would
+        // make the tool annoyingly slow.
+        .stderr(unable_to_synthesize_license_expr_warning(&package))
+        .stdout(overview_count(0))
+        .stdout(licenses_count(0));
+
+    Ok(())
+}
+
+#[test]
+fn reports_custom_spdx_license_file_when_spdx_license_file_has_std_naming_but_not_specifed_in_manifest(
+) -> Result<()> {
+    let license_content = mit_license_text("2022", "Big Birdz");
+
+    let package = Package::builder()
+        .license(Some("MIT"))
+        .file("LICENSE", &license_content)
+        .accepted(&["MIT"])
+        .build()?;
+
+    CargoAbout::new(&package)?
+        .generate()
+        .template(package.template()?)
+        .assert()
+        .success()
+        .stderr("")
+        .stdout(overview_count(1))
+        .stdout(licenses_count(1))
+        .stdout(predicates::str::contains(&license_content));
+
+    Ok(())
+}
+
+#[test]
+fn reports_one_license_when_when_dependency_has_same_spdx_license_and_text() -> Result<()> {
+    let package_b = Package::builder()
+        .name("package-b")
+        .license(Some("MIT"))
+        .build()?;
+
+    let package_a = Package::builder()
+        .name("package-a")
+        .license(Some("MIT"))
+        .accepted(&["MIT"])
+        .dependency(&package_b)
+        .build()?;
+
+    CargoAbout::new(&package_a)?
+        .generate()
+        .template(package_a.template()?)
+        .assert()
+        .success()
+        .stderr("")
+        .stdout(overview_count(1))
+        .stdout(licenses_count(1))
+        .stdout(contains_default_mit_license_content());
+
+    Ok(())
+}
+
+#[test]
+fn reports_all_licenses_when_dependency_has_same_spdx_license_and_different_text() -> Result<()> {
+    let package_b_license_text = mit_license_text("2022", "Package B Owner");
+    let package_b = Package::builder()
+        .license_file("LICENSE", Some(&package_b_license_text))
+        .name("package-b")
+        .build()?;
+
+    let package_a_license_text = mit_license_text("2022", "Package A Owner");
+    let package_a = Package::builder()
+        .name("package-a")
+        .license_file("LICENSE", Some(&package_a_license_text))
+        .dependency(&package_b)
+        .accepted(&["MIT"])
+        .build()?;
+
+    CargoAbout::new(&package_a)?
+        .generate()
+        .template(package_a.template()?)
+        .assert()
+        .success()
+        .stdout(overview_count(1))
+        .stdout(licenses_count(2))
+        .stdout(predicates::str::contains(package_a_license_text))
+        .stdout(predicates::str::contains(package_b_license_text));
+
+    Ok(())
+}
+
+#[test]
+fn reports_all_licenses_when_dependency_has_different_license_and_text() -> Result<()> {
+    let package_b = Package::builder()
+        .name("package-b")
+        .license(Some("Apache-2.0"))
+        .build()?;
+
+    let package_a = Package::builder()
+        .name("package-a")
+        .license(Some("MIT"))
+        .dependency(&package_b)
+        .accepted(&["MIT", "Apache-2.0"])
+        .build()?;
+
+    CargoAbout::new(&package_a)?
+        .generate()
+        .template(package_a.template()?)
+        .assert()
+        .success()
+        .stdout(overview_count(2))
+        .stdout(licenses_count(2))
+        .stdout(contains_default_mit_license_content())
+        .stdout(contains_default_apache2_license_content());
+
+    Ok(())
+}
+
+#[test]
+fn fails_when_dependency_has_non_accepted_license_field() -> Result<()> {
+    let mut package_builder = Package::builder();
+
+    let package_b = package_builder
+        .license(Some("Apache-2.0"))
+        .name("package-b")
+        .build()?;
+
+    let package_a = package_builder
+        .license(Some("MIT"))
+        .name("package-a")
+        .accepted(&["MIT"])
+        .dependency(&package_b)
+        .build()?;
+
+    CargoAbout::new(&package_a)?
+        .generate()
+        .template(package_a.template()?)
+        .assert()
+        .failure()
+        .stderr(predicates::str::contains(
+            "encountered 1 errors resolving licenses, unable to generate output",
+        ));
+
+    Ok(())
+}
diff --git a/tests/cli/init.rs b/tests/cli/init.rs
new file mode 100644
index 0000000..421a391
--- /dev/null
+++ b/tests/cli/init.rs
@@ -0,0 +1,147 @@
+use crate::utils::*;
+
+use anyhow::Result;
+use assert_fs::prelude::*;
+use predicates::prelude::*;
+
+#[test]
+fn fails_when_manifest_absent() -> Result<()> {
+    let package = Package::builder().no_manifest().build()?;
+
+    CargoAbout::new(&package)?
+        .init()
+        .assert()
+        .failure()
+        .stderr(predicate::str::contains("could not find `Cargo.toml`"));
+    Ok(())
+}
+
+#[test]
+fn fails_when_manifest_empty() -> Result<()> {
+    let package = Package::builder().file("Cargo.toml", "").build()?;
+
+    CargoAbout::new(&package)?
+        .init()
+        .assert()
+        .failure()
+        .stderr(predicate::str::contains("failed to parse manifest"));
+
+    Ok(())
+}
+
+#[test]
+fn writes_config_and_template_by_default() -> Result<()> {
+    let package = Package::builder().no_template().no_about_config().build()?;
+
+    CargoAbout::new(&package)?.init().assert().success();
+
+    let dir = &package.dir;
+    dir.child(ABOUT_CONFIG_FILENAME)
+        .assert(predicate::path::exists());
+    dir.child(ABOUT_TEMPLATE_FILENAME)
+        .assert(predicate::path::exists());
+
+    Ok(())
+}
+
+#[test]
+fn writes_config_only_when_no_handlebars_specifed() -> Result<()> {
+    let package = Package::builder().no_template().no_about_config().build()?;
+
+    CargoAbout::new(&package)?
+        .init()
+        .arg("--no-handlebars")
+        .assert()
+        .success()
+        .stdout("")
+        .stderr("");
+
+    let dir = &package.dir;
+    dir.child(ABOUT_CONFIG_FILENAME)
+        .assert(predicate::path::exists());
+    dir.child(ABOUT_TEMPLATE_FILENAME)
+        .assert(predicate::path::missing());
+
+    Ok(())
+}
+
+#[test]
+fn does_not_overwrite_by_default() -> Result<()> {
+    let template_content = "A useless custom template";
+    let config_content = "A useless invalid config";
+
+    let package = Package::builder()
+        .file(ABOUT_TEMPLATE_FILENAME, template_content)
+        .file(ABOUT_CONFIG_FILENAME, config_content)
+        .build()?;
+
+    CargoAbout::new(&package)?
+        .init()
+        .assert()
+        .success()
+        .stdout("")
+        .stderr("");
+
+    let config = &package.dir.child(ABOUT_CONFIG_FILENAME);
+    let template = &package.dir.child(ABOUT_TEMPLATE_FILENAME);
+
+    assert_eq!(std::fs::read_to_string(&config)?, config_content);
+    assert_eq!(std::fs::read_to_string(&template)?, template_content);
+
+    Ok(())
+}
+
+#[test]
+fn overwrites_config_and_template_when_overwrite_specified() -> Result<()> {
+    let template_content = "A useless custom template";
+    let config_content = "A useless invalid config";
+
+    let package = Package::builder()
+        .file(ABOUT_TEMPLATE_FILENAME, template_content)
+        .file(ABOUT_CONFIG_FILENAME, config_content)
+        .build()?;
+
+    CargoAbout::new(&package)?
+        .init()
+        .arg("--overwrite")
+        .assert()
+        .success()
+        .stdout("")
+        .stderr("");
+
+    let config = &package.dir.child(ABOUT_CONFIG_FILENAME);
+    let template = &package.dir.child(ABOUT_TEMPLATE_FILENAME);
+
+    assert_ne!(std::fs::read_to_string(&config)?, config_content);
+    assert_ne!(std::fs::read_to_string(&template)?, template_content);
+
+    Ok(())
+}
+
+#[test]
+fn overwrites_config_only_when_no_handlebars_and_overwrite_specified() -> Result<()> {
+    let template_content = "A useless custom template";
+    let config_content = "A useless invalid config";
+
+    let package = Package::builder()
+        .file(ABOUT_TEMPLATE_FILENAME, template_content)
+        .file(ABOUT_CONFIG_FILENAME, config_content)
+        .build()?;
+
+    CargoAbout::new(&package)?
+        .init()
+        .arg("--no-handlebars")
+        .arg("--overwrite")
+        .assert()
+        .success()
+        .stdout("")
+        .stderr("");
+
+    let config = &package.dir.child(ABOUT_CONFIG_FILENAME);
+    let template = &package.dir.child(ABOUT_TEMPLATE_FILENAME);
+
+    assert_ne!(std::fs::read_to_string(&config)?, config_content);
+    assert_eq!(std::fs::read_to_string(&template)?, template_content);
+
+    Ok(())
+}
diff --git a/tests/cli/mod.rs b/tests/cli/mod.rs
new file mode 100644
index 0000000..089c0d4
--- /dev/null
+++ b/tests/cli/mod.rs
@@ -0,0 +1,2 @@
+mod generate;
+mod init;
diff --git a/tests/utils/cargo_about.rs b/tests/utils/cargo_about.rs
new file mode 100644
index 0000000..69179bb
--- /dev/null
+++ b/tests/utils/cargo_about.rs
@@ -0,0 +1,39 @@
+use crate::utils::Package;
+
+use anyhow::Result;
+use assert_cmd::assert::Assert;
+use assert_cmd::prelude::*;
+use std::process::Command;
+
+pub struct CargoAbout {
+    cmd: Command,
+}
+
+impl CargoAbout {
+    pub fn new(package: &Package) -> Result<Self> {
+        let mut cmd = Command::cargo_bin("cargo-about")?;
+        cmd.current_dir(&package.dir);
+        Ok(CargoAbout { cmd })
+    }
+
+    pub fn arg(&mut self, arg: &str) -> &mut Self {
+        self.cmd.arg(arg);
+        self
+    }
+
+    pub fn init(&mut self) -> &mut Self {
+        self.arg("init")
+    }
+
+    pub fn generate(&mut self) -> &mut Self {
+        self.arg("generate")
+    }
+
+    pub fn template(&mut self, template: &str) -> &mut Self {
+        self.arg(template)
+    }
+
+    pub fn assert(&mut self) -> Assert {
+        self.cmd.assert()
+    }
+}
diff --git a/tests/utils/mod.rs b/tests/utils/mod.rs
new file mode 100644
index 0000000..7ab0903
--- /dev/null
+++ b/tests/utils/mod.rs
@@ -0,0 +1,7 @@
+mod cargo_about;
+mod package;
+mod predicates;
+
+pub use self::cargo_about::*;
+pub use self::package::*;
+pub use self::predicates::*;
diff --git a/tests/utils/package.rs b/tests/utils/package.rs
new file mode 100644
index 0000000..530cde6
--- /dev/null
+++ b/tests/utils/package.rs
@@ -0,0 +1,265 @@
+use anyhow::{anyhow, Result};
+use assert_fs::prelude::*;
+use assert_fs::TempDir;
+use std::collections::HashMap;
+use std::collections::HashSet;
+use std::fmt::Debug;
+
+pub const CARGO_MANIFEST_FILENAME: &str = "Cargo.toml";
+pub const ABOUT_CONFIG_FILENAME: &str = "about.toml";
+pub const ABOUT_TEMPLATE_FILENAME: &str = "about.hbs";
+
+pub struct Package {
+    pub dir: TempDir,
+    pub name: String,
+    pub version: String,
+    pub template_filename: Option<String>,
+}
+
+impl Package {
+    pub fn template(&self) -> Result<&str> {
+        match self.template_filename.as_ref() {
+            Some(template) => Ok(template),
+            None => Err(anyhow!("Package '{}' has no template", self.name)),
+        }
+    }
+
+    pub fn builder<'a>() -> PackageBuilder<'a> {
+        PackageBuilder::default()
+    }
+}
+
+// TODO: This could be improved by indenting file contents, but is still
+// useful for debugging cli tests.
+impl Debug for Package {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        writeln!(f, "Package")?;
+        writeln!(f, "{}:", CARGO_MANIFEST_FILENAME)?;
+        writeln!(
+            f,
+            "{}",
+            std::fs::read_to_string(self.dir.child(CARGO_MANIFEST_FILENAME))
+                .unwrap_or_else(|_| "Couldn't read file.".into())
+        )?;
+        writeln!(f, "{}:", ABOUT_CONFIG_FILENAME)?;
+        writeln!(
+            f,
+            "{}",
+            std::fs::read_to_string(self.dir.child(ABOUT_CONFIG_FILENAME))
+                .unwrap_or_else(|_| "Couldn't read file.".into())
+        )?;
+        writeln!(
+            f,
+            "src/main.rs exists: {}",
+            self.dir.child("src/main.rs").exists()
+        )
+    }
+}
+
+pub struct PackageBuilder<'a> {
+    name: String,
+    version: String,
+    license: Option<String>,
+    license_filename: Option<String>,
+    accepted: HashSet<String>,
+    files: HashMap<String, String>,
+    excludes: HashSet<String>,
+    dependencies: Vec<&'a Package>,
+}
+
+impl Default for PackageBuilder<'_> {
+    fn default() -> Self {
+        PackageBuilder {
+            name: "package".into(),
+            version: "0.0.0".into(),
+            license: None,
+            license_filename: None,
+            accepted: HashSet::new(),
+            files: HashMap::new(),
+            excludes: HashSet::new(),
+            dependencies: Vec::new(),
+        }
+    }
+}
+
+impl<'a> PackageBuilder<'a> {
+    pub fn no_manifest(&mut self) -> &mut Self {
+        self.excludes.insert(CARGO_MANIFEST_FILENAME.into());
+        self
+    }
+
+    #[allow(dead_code)]
+    pub fn no_about_config(&mut self) -> &mut Self {
+        self.excludes.insert(ABOUT_CONFIG_FILENAME.into());
+        self
+    }
+
+    pub fn no_template(&mut self) -> &mut Self {
+        self.excludes.insert(ABOUT_TEMPLATE_FILENAME.into());
+        self
+    }
+
+    pub fn name(&mut self, name: &str) -> &mut Self {
+        self.name = name.into();
+        self
+    }
+
+    #[allow(dead_code)]
+    pub fn version(&mut self, version: &str) -> &mut Self {
+        self.version = version.into();
+        self
+    }
+
+    pub fn license(&mut self, license: Option<&str>) -> &mut Self {
+        self.license = license.map(|s| s.into());
+        self
+    }
+
+    pub fn license_file(&mut self, filename: &str, content: Option<&str>) -> &mut Self {
+        self.license_filename = Some(filename.into());
+        if let Some(content) = content {
+            self.files.insert(filename.into(), content.into());
+        } else {
+            self.files.remove(filename);
+        }
+        self
+    }
+
+    pub fn file(&mut self, filename: &str, content: &str) -> &mut Self {
+        self.files.insert(filename.into(), content.into());
+        self
+    }
+
+    pub fn accepted(&mut self, accepted: &[&str]) -> &mut Self {
+        self.accepted = accepted
+            .iter()
+            .map(|s| s.to_string())
+            .collect::<HashSet<_>>();
+        self
+    }
+
+    pub fn dependency(&mut self, package: &'a Package) -> &mut Self {
+        self.dependencies.push(package);
+        self
+    }
+
+    fn not_overridden_or_excluded(&self, filename: &str) -> bool {
+        !self.files.contains_key(filename) && !self.excludes.contains(filename)
+    }
+
+    fn write_default_cargo_manifest(&self, dir: &TempDir) -> Result<()> {
+        if self.not_overridden_or_excluded(CARGO_MANIFEST_FILENAME) {
+            let mut manifest = toml_edit::Document::new();
+            let package = &mut manifest["package"];
+            *package = toml_edit::table();
+
+            package["name"] = toml_edit::value(self.name.clone());
+            package["version"] = toml_edit::value(self.version.clone());
+
+            if let Some(license) = &self.license {
+                package["license"] = toml_edit::value(license.clone());
+            }
+            if let Some(license_filename) = &self.license_filename {
+                package["license_file"] = toml_edit::value(license_filename.clone());
+            }
+
+            if !self.dependencies.is_empty() {
+                let dependencies = &mut manifest["dependencies"];
+                *dependencies = toml_edit::table();
+                for package in &self.dependencies {
+                    dependencies[&package.name]["version"] =
+                        toml_edit::value(package.version.to_string());
+                    dependencies[&package.name]["path"] =
+                        toml_edit::value(package.dir.to_str().unwrap());
+                }
+            }
+
+            dir.child(CARGO_MANIFEST_FILENAME)
+                .write_str(&manifest.to_string())?;
+        }
+
+        Ok(())
+    }
+
+    fn write_default_about_config(&self, dir: &TempDir) -> Result<()> {
+        if self.not_overridden_or_excluded(ABOUT_CONFIG_FILENAME) {
+            let mut config = toml_edit::Document::new();
+            config["accepted"] =
+                toml_edit::value(toml_edit::Array::from_iter(self.accepted.iter()));
+
+            dir.child(ABOUT_CONFIG_FILENAME)
+                .write_str(&config.to_string())?;
+        }
+
+        Ok(())
+    }
+
+    // required for package to contain at least one valid crate
+    fn write_default_lib(&self, dir: &TempDir) -> Result<()> {
+        let filename = "src/lib.rs";
+        if self.not_overridden_or_excluded(filename) {
+            dir.child(filename).write_str("")?;
+        }
+
+        Ok(())
+    }
+
+    fn write_default_template_if_absent(&self, dir: &TempDir) -> Result<()> {
+        if self.not_overridden_or_excluded(ABOUT_TEMPLATE_FILENAME) {
+            // Getting the number of overview and licenses elements
+            // by repeating a single letter on a line. This is a
+            // workaround for the fact that there doesn't seem to
+            // be a built in helper/property for getting a list's
+            // length in the rust implementation of handlebars.
+            dir.child(ABOUT_TEMPLATE_FILENAME).write_str(
+                "\
+                    The following line is used to assert on overview count:\n\
+                    #o:[{{#each overview}}o{{/each}}]\n\
+                    \n\
+                    The following line is used to assert on license count:\n\
+                    #l:[{{#each licenses}}l{{/each}}]\n\
+                    \n\
+                    The following is used to assert on license text (note that\n\
+                    the raw (non-html-escaped) text is used to make assertions\n\
+                    easier:\n\
+                    {{#each licenses}}\n\
+                    {{{text}}}\n\
+                    {{/each}}\n\
+                ",
+            )?;
+        }
+
+        Ok(())
+    }
+
+    pub fn write_files(&self, dir: &TempDir) -> Result<()> {
+        for (filename, content) in &self.files {
+            if !self.excludes.contains(filename) {
+                let file = dir.child(&filename);
+                file.write_str(content)?;
+            }
+        }
+
+        Ok(())
+    }
+
+    pub fn build(&self) -> Result<Package> {
+        let dir = TempDir::new()?;
+        self.write_default_cargo_manifest(&dir)?;
+        self.write_default_about_config(&dir)?;
+        self.write_default_lib(&dir)?;
+        self.write_default_template_if_absent(&dir)?;
+        self.write_files(&dir)?;
+
+        Ok(Package {
+            dir,
+            template_filename: if self.excludes.contains(ABOUT_TEMPLATE_FILENAME) {
+                None
+            } else {
+                Some(ABOUT_TEMPLATE_FILENAME.into())
+            },
+            name: self.name.clone(),
+            version: self.version.clone(),
+        })
+    }
+}
diff --git a/tests/utils/predicates.rs b/tests/utils/predicates.rs
new file mode 100644
index 0000000..de28472
--- /dev/null
+++ b/tests/utils/predicates.rs
@@ -0,0 +1,65 @@
+use crate::utils::Package;
+
+use predicates::prelude::*;
+use predicates::str::contains;
+use predicates::Predicate;
+
+pub fn mit_license_text(year: &str, copyright_holder: &str) -> String {
+    format! ("\
+            Copyright (c) {} {}\n\
+            \n\
+            Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\
+            \n\
+            The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\
+            \n\
+            THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n\
+    ",
+    year,
+    copyright_holder
+    )
+}
+
+pub fn contains_default_mit_license_content() -> impl Predicate<str> {
+    contains_mit_license_content("<year>", "<copyright holders>")
+}
+
+pub fn contains_default_apache2_license_content() -> impl Predicate<str> {
+    contains_apache2_license_content("[yyyy]", "[name of copyright owner]")
+}
+
+pub fn contains_mit_license_content(year: &str, copyright_holder: &str) -> impl Predicate<str> {
+    contains(mit_license_text(year, copyright_holder))
+}
+
+pub fn contains_apache2_license_content(year: &str, copyright_holder: &str) -> impl Predicate<str> {
+    let header = "\
+        Apache License\n\
+        Version 2.0, January 2004\n\
+    ";
+    let copyright = format!("Copyright {} {}", year, copyright_holder);
+
+    contains(header).and(contains(copyright))
+}
+
+pub fn overview_count(count: usize) -> impl Predicate<str> {
+    contains(format!("#o:[{}]", "o".repeat(count)))
+}
+
+pub fn licenses_count(count: usize) -> impl Predicate<str> {
+    contains(format!("#l:[{}]", "l".repeat(count)))
+}
+
+pub fn contains_missing_license_field_warning(package: &Package) -> impl Predicate<str> {
+    contains(format!(
+        "crate '{} {}' doesn't have a license field",
+        package.name, package.version
+    ))
+}
+
+pub fn unable_to_synthesize_license_expr_warning(package: &Package) -> impl Predicate<str> {
+    contains(format!(
+        "unable to synthesize license expression for '{} {}': \
+            no `license` specified, and no license files were found",
+        package.name, package.version
+    ))
+}