Repository: spectacles-ci/spectacles

Issue:
Move SQL in error object to metadata
Since the `test` field of the `ValidationError` is only used by `SqlError`, move it to the error's `metadata` and remove the `test` attribute.

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/spectacles-ci/spectacles/pull/247?src=pr&el=h1) Report
> Merging [#247](https://codecov.io/gh/spectacles-ci/spectacles/pull/247?src=pr&el=desc) into [master](https://codecov.io/gh/spectacles-ci/spectacles/commit/48b947e8ac1d375487d714fea30d19566965cb51&el=desc) will **decrease** coverage by `0.00%`.
> The diff coverage is `n/a`.

[![Impacted file tree graph](https://codecov.io/gh/spectacles-ci/spectacles/pull/247/graphs/tree.svg?width=650&height=150&src=pr&token=EWgmmQti5j)](https://codecov.io/gh/spectacles-ci/spectacles/pull/247?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #247      +/-   ##
==========================================
- Coverage   89.74%   89.73%   -0.01%     
==========================================
  Files          13       13              
  Lines        1063     1062       -1     
==========================================
- Hits          954      953       -1     
  Misses        109      109              
```


| [Impacted Files](https://codecov.io/gh/spectacles-ci/spectacles/pull/247?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [spectacles/cli.py](https://codecov.io/gh/spectacles-ci/spectacles/pull/247/diff?src=pr&el=tree#diff-c3BlY3RhY2xlcy9jbGkucHk=) | `78.48% <ø> (ø)` | |
| [spectacles/exceptions.py](https://codecov.io/gh/spectacles-ci/spectacles/pull/247/diff?src=pr&el=tree#diff-c3BlY3RhY2xlcy9leGNlcHRpb25zLnB5) | `93.18% <ø> (-0.16%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/spectacles-ci/spectacles/pull/247?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/spectacles-ci/spectacles/pull/247?src=pr&el=footer). Last update [48b947e...bd64179](https://codecov.io/gh/spectacles-ci/spectacles/pull/247?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/README.md b/README.md
index 396f6bf..b04958e 100644
--- a/README.md
+++ b/README.md
@@ -10,9 +10,9 @@
 
 You can run the following validators as subcommands (e.g. `spectacles sql`):
 
-- [**SQL** validation](https://docs.spectacles.dev/tutorials/validators#the-sql-validator) - tests for database errors or invalid SQL
-- [**Assert** validation](https://docs.spectacles.dev/tutorials/validators#the-assert-validator) - runs [LookML/data tests](https://docs.looker.com/reference/model-params/test)
-- **Content** validation _(coming soon)_
+- [**SQL** validation](https://docs.spectacles.dev/tutorials/validators#the-sql-validator) - tests the `sql` field of each dimension for database errors
+- [**Assert** validation](https://docs.spectacles.dev/tutorials/validators#the-assert-validator) - runs [Looker data tests](https://docs.looker.com/reference/model-params/test)
+- [**Content** validation](https://docs.spectacles.dev/tutorials/validators#the-content-validator) - tests for errors in Looks and Dashboards
 - **Linting** _(coming soon)_
 
 ## Documentation
diff --git a/RELEASE.md b/RELEASE.md
new file mode 100644
index 0000000..427b3fd
--- /dev/null
+++ b/RELEASE.md
@@ -0,0 +1,57 @@
+# Releasing a new version of Spectacles
+
+## Follow semver
+Spectacles follows semantic versioning. Familiarize yourself with [semver](https://semver.org/) before creating a new version of Spectacles. Non-development releases should be approved by all project maintainers.
+
+## Write a draft release on GitHub
+*Doesn't apply to development versions (alpha, beta, or release candidate)*
+
+1. The release notes should have two sections: **Features** for new functionality and **Fixes** for bugfixes or internal changes.
+
+1. Include links for each feature or fix to any relevant GitHub issues or documentation sections. See [previous releases](https://github.com/spectacles-ci/spectacles/releases/tag/v0.1.1) for examples.
+
+1. Save the release as a draft. If you'd like, you can draft and edit the release notes as you merge features and fixes, it's not public.
+
+## Get code and docs ready
+
+All code to be released should be merged into `master`. If the release is a major or minor release, there should be a corresponding, approved PR on the docs repository ready to be merged that covers any notable new functionality or API changes.
+
+## Bump the version
+
+The version number for Spectacles is stored in the `__init__.py` file in `spectacles/` as the argument `__version__`.
+
+Change the version there and commit it with the message "Bump version [CURRENT_VERSION] > [NEW_VERSION]". You can commit this directly to `master` or create a separate branch (e.g. `release/0.2.2`) for this change.
+
+## Build distributions
+To build distributions, run the following command from the root of the Spectacles repository on the `master` branch.
+
+```bash
+python setup.py sdist bdist_wheel
+```
+
+This command will build distributions in the `dist` directory within the Spectacles repository root.
+
+## Deploy to PyPi
+To deploy a relase, you must have an account on PyPi with at least Maintainer role on the [Spectacles project](https://pypi.org/manage/project/spectacles/collaboration/).
+
+Next, install [`twine`](https://twine.readthedocs.io/en/latest/#installation) and optionally [set up Keyring](https://twine.readthedocs.io/en/latest/#keyring-support) so you don't have to enter your username and password each time.
+
+To deploy to production PyPi, use this command:
+```bash
+twine upload --skip-existing dist/*
+```
+
+To deploy to the test PyPi instance, use this command instead:
+```bash
+twine upload --skip-existing --repository testpypi dist/*
+```
+
+## Publish draft GitHub release
+*Doesn't apply to development versions (alpha, beta, or release candidate)*
+
+Publish the draft release. This will generate a corresponding tag on the HEAD commit of `master`.
+
+## Merge the docs PR and announce
+*Doesn't apply to development versions (alpha, beta, or release candidate)*
+
+At this point, the release is ready to share with the world. Merge any corresponding docs PR and announce the release in Slack!
\ No newline at end of file
diff --git a/spectacles/cli.py b/spectacles/cli.py
index bb7f3d8..649fc09 100644
--- a/spectacles/cli.py
+++ b/spectacles/cli.py
@@ -19,6 +19,7 @@ from spectacles.exceptions import (
 from spectacles.logger import GLOBAL_LOGGER as logger, set_file_handler
 import spectacles.printer as printer
 import spectacles.tracking as tracking
+from spectacles.utils import log_duration
 
 
 class ConfigFileAction(argparse.Action):
@@ -262,6 +263,23 @@ def main():
             args.import_projects,
             args.commit_ref,
         )
+    elif args.command == "content":
+        run_content(
+            args.project,
+            args.branch,
+            args.explores,
+            args.exclude,
+            args.base_url,
+            args.client_id,
+            args.client_secret,
+            args.port,
+            args.api_version,
+            args.remote_reset,
+            args.import_projects,
+            args.commit_ref,
+            args.incremental,
+            args.exclude_personal,
+        )
 
     if not args.do_not_track:
         tracking.track_invocation_end(
@@ -288,6 +306,7 @@ def create_parser() -> argparse.ArgumentParser:
     _build_connect_subparser(subparser_action, base_subparser)
     _build_sql_subparser(subparser_action, base_subparser)
     _build_assert_subparser(subparser_action, base_subparser)
+    _build_content_subparser(subparser_action, base_subparser)
     return parser
 
 
@@ -524,6 +543,29 @@ def _build_assert_subparser(
     _build_validator_subparser(subparser_action, subparser)
 
 
+def _build_content_subparser(
+    subparser_action: argparse._SubParsersAction,
+    base_subparser: argparse.ArgumentParser,
+) -> None:
+    subparser = subparser_action.add_parser(
+        "content", parents=[base_subparser], help="Run Looker content validation."
+    )
+
+    subparser.add_argument(
+        "--incremental",
+        action="store_true",
+        help="Only display errors which are not present on the master branch.",
+    )
+
+    subparser.add_argument(
+        "--exclude-personal",
+        action="store_true",
+        help="Exclude errors found in content in personal folders.",
+    )
+
+    _build_validator_subparser(subparser_action, subparser)
+
+
 def run_connect(
     base_url: str, client_id: str, client_secret: str, port: int, api_version: float
 ) -> None:
@@ -531,6 +573,63 @@ def run_connect(
     LookerClient(base_url, client_id, client_secret, port, api_version)
 
 
+@log_duration
+def run_content(
+    project,
+    branch,
+    explores,
+    excludes,
+    base_url,
+    client_id,
+    client_secret,
+    port,
+    api_version,
+    remote_reset,
+    import_projects,
+    commit_ref,
+    incremental,
+    exclude_personal,
+) -> None:
+    runner = Runner(
+        base_url,
+        project,
+        branch,
+        client_id,
+        client_secret,
+        port,
+        api_version,
+        remote_reset,
+        import_projects,
+        commit_ref,
+    )
+    results = runner.validate_content(explores, excludes, incremental, exclude_personal)
+
+    for test in sorted(results["tested"], key=lambda x: (x["model"], x["explore"])):
+        message = f"{test['model']}.{test['explore']}"
+        printer.print_validation_result(passed=test["passed"], source=message)
+
+    errors = sorted(
+        results["errors"],
+        key=lambda x: (x["model"], x["explore"], x["metadata"]["field_name"]),
+    )
+    if errors:
+        for error in errors:
+            printer.print_content_error(
+                error["model"],
+                error["explore"],
+                error["message"],
+                error["metadata"]["content_type"],
+                error["metadata"]["space"],
+                error["metadata"]["title"],
+                error["metadata"]["url"],
+            )
+        logger.info("")
+        raise GenericValidationError
+    else:
+        logger.info("")
+
+
+@log_duration
 def run_assert(
     project,
     branch,
@@ -578,6 +677,7 @@ def run_assert(
         logger.info("")
 
 
+@log_duration
 def run_sql(
     log_dir,
     project,
@@ -615,6 +715,11 @@ def run_sql(
                 yield item
 
     results = runner.validate_sql(explores, exclude, mode, concurrency)
+
+    for test in sorted(results["tested"], key=lambda x: (x["model"], x["explore"])):
+        message = f"{test['model']}.{test['explore']}"
+        printer.print_validation_result(passed=test["passed"], source=message)
+
     errors = sorted(
         results["errors"],
         key=lambda x: (x["model"], x["explore"], x["metadata"].get("dimension")),
@@ -626,16 +731,20 @@ def run_sql(
                 model=error["model"],
                 explore=error["explore"],
                 message=error["message"],
-                sql=error["test"],
+                sql=error["metadata"]["sql"],
                 log_dir=log_dir,
                 dimension=error["metadata"].get("dimension"),
                 lookml_url=error["metadata"].get("lookml_url"),
             )
         if mode == "batch":
             logger.info(
-                f"\n\nTo determine the exact dimensions responsible for {'this error' if len(errors) == 1 else 'these errors'}, "
-                f"you can re-run \nSpectacles in single-dimension mode, with `--mode single`.\n\n"
-                "You can also run this original validation with `--mode hybrid` to do this automatically."
+                printer.dim(
+                    "\n\nTo determine the exact dimensions responsible for "
+                    f"{'this error' if len(errors) == 1 else 'these errors'}, "
+                    "you can re-run \nSpectacles in single-dimension mode, "
+                    "with `--mode single`.\n\nYou can also run this original "
+                    "validation with `--mode hybrid` to do this automatically."
+                )
             )
 
         logger.info("")
diff --git a/spectacles/client.py b/spectacles/client.py
index f7e324c..0f5b1a2 100644
--- a/spectacles/client.py
+++ b/spectacles/client.py
@@ -323,7 +323,7 @@ class LookerClient:
                 status=response.status_code,
                 detail=(
                     f"Unable to get active branch for project '{project}'. "
-                    "Please try again."
+                    "Please check that the project exists and try again."
                 ),
                 response=response,
             )
@@ -718,3 +718,41 @@ class LookerClient:
 
         # No raise_for_status() here because Looker API seems to give a 404
         # if you try to cancel a finished query which can happen as part of cleanup
+
+    def content_validation(self) -> JsonDict:
+        logger.debug(f"Validating all content in Looker")
+        url = utils.compose_url(self.api_url, path=["content_validation"])
+        response = self.get(url=url, timeout=TIMEOUT_SEC)
+
+        try:
+            response.raise_for_status()
+        except requests.exceptions.HTTPError:
+            raise LookerApiError(
+                name="unable-to-validate-content",
+                title="Couldn't validate Looks and Dashboards.",
+                status=response.status_code,
+                detail=("Failed to run the content validator. Please try again."),
+                response=response,
+            )
+
+        result = response.json()
+        return result
+
+    def all_folders(self, project: str) -> List[JsonDict]:
+        logger.debug("Getting information about all folders")
+        url = utils.compose_url(self.api_url, path=["folders"])
+        response = self.get(url=url, timeout=TIMEOUT_SEC)
+
+        try:
+            response.raise_for_status()
+        except requests.exceptions.HTTPError:
+            raise LookerApiError(
+                name="unable-to-get-folders",
+                title="Couldn't obtain project folders.",
+                status=response.status_code,
+                detail=(f"Failed to get all folders for project '{project}'."),
+                response=response,
+            )
+
+        result = response.json()
+        return result
diff --git a/spectacles/exceptions.py b/spectacles/exceptions.py
index b678df2..897348a 100644
--- a/spectacles/exceptions.py
+++ b/spectacles/exceptions.py
@@ -66,16 +66,10 @@ class GenericValidationError(SpectaclesException):
 
 class ValidationError(GenericValidationError):
     def __init__(
-        self,
-        model: str,
-        explore: str,
-        test: Optional[str],
-        message: str,
-        metadata: Dict[str, Any],
+        self, model: str, explore: str, message: str, metadata: Dict[str, Any]
     ):
         self.model = model
         self.explore = explore
-        self.test = test
         self.message = message
         self.metadata = metadata
         super().__init__()
@@ -104,12 +98,13 @@ class SqlError(ValidationError):
     ):
         metadata = {
             "dimension": dimension,
+            "sql": sql,
             "line_number": line_number,
             "explore_url": explore_url,
             "lookml_url": lookml_url,
         }
         super().__init__(
-            model=model, explore=explore, test=sql, message=message, metadata=metadata
+            model=model, explore=explore, message=message, metadata=metadata
         )
 
 
@@ -118,6 +113,30 @@ class DataTestError(ValidationError):
         self, model: str, explore: str, message: str, test_name: str, lookml_url: str
     ):
         metadata = {"test_name": test_name, "lookml_url": lookml_url}
+        super().__init__(
+            model=model, explore=explore, message=message, metadata=metadata
+        )
+
+
+class ContentError(ValidationError):
+    def __init__(
+        self,
+        model: str,
+        explore: str,
+        message: str,
+        field_name: str,
+        content_type: str,
+        title: str,
+        space: str,
+        url: str,
+    ):
+        metadata = {
+            "field_name": field_name,
+            "content_type": content_type,
+            "title": title,
+            "space": space,
+            "url": url,
+        }
         super().__init__(
             model=model, explore=explore, test=None, message=message, metadata=metadata
         )
diff --git a/spectacles/lookml.py b/spectacles/lookml.py
index 3dd80f2..ae9b716 100644
--- a/spectacles/lookml.py
+++ b/spectacles/lookml.py
@@ -1,6 +1,6 @@
 import re
 from typing import Dict, List, Sequence, Optional, Any
-from spectacles.exceptions import SqlError
+from spectacles.exceptions import ValidationError
 from spectacles.types import QueryMode
 
 
@@ -26,7 +26,7 @@ class Dimension(LookMlObject):
         self.sql = sql
         self.url = url
         self.queried: bool = False
-        self.error: Optional[SqlError] = None
+        self.errors: List[ValidationError] = []
         if re.search(r"spectacles\s*:\s*ignore", sql, re.IGNORECASE):
             self.ignore = True
         else:
@@ -53,14 +53,14 @@ class Dimension(LookMlObject):
 
     @property
     def errored(self):
-        return bool(self.error) if self.queried else None
+        return bool(self.errors) if self.queried else None
 
     @errored.setter
     def errored(self, value):
         raise AttributeError(
             "Cannot assign to 'errored' property of a Dimension instance. "
-            "For a dimension to be considered errored, it must have a SqlError "
-            "in its 'error' attribute."
+            "For a dimension to be considered errored, it must have a ValidationError "
+            "in its 'errors' attribute."
         )
 
     @classmethod
@@ -77,8 +77,8 @@ class Explore(LookMlObject):
         self.name = name
         self.model_name = model_name
         self.dimensions = [] if dimensions is None else dimensions
-        self.queried: bool = False
-        self.error: Optional[SqlError] = None
+        self.errors: List[ValidationError] = []
+        self._queried: bool = False
 
     def __eq__(self, other):
         if not isinstance(other, Explore):
@@ -90,10 +90,27 @@ class Explore(LookMlObject):
             and self.dimensions == other.dimensions
         )
 
+    @property
+    def queried(self):
+        if self.dimensions:
+            return any(dimension.queried for dimension in self.dimensions)
+        else:
+            return self._queried
+
+    @queried.setter
+    def queried(self, value: bool):
+        if not isinstance(value, bool):
+            raise TypeError("Value for queried must be boolean.")
+        if self.dimensions:
+            for dimension in self.dimensions:
+                dimension.queried = value
+        else:
+            self._queried = value
+
     @property
     def errored(self):
         if self.queried:
-            return bool(self.error) or any(
+            return bool(self.errors) or any(
                 dimension.errored for dimension in self.dimensions
             )
         else:
@@ -103,22 +120,10 @@ class Explore(LookMlObject):
     def errored(self, value):
         raise AttributeError(
             "Cannot assign to 'errored' property of an Explore instance. "
-            "For an explore to be considered errored, it must have a SqlError "
-            "in its 'error' attribute or contain dimensions in an errored state."
+            "For an explore to be considered errored, it must have a ValidationError "
+            "in its 'errors' attribute or contain dimensions in an errored state."
         )
 
-    @property
-    def queried(self):
-        return any(dimension.queried for dimension in self.dimensions)
-
-    @queried.setter
-    def queried(self, value: bool):
-        if not isinstance(value, bool):
-            raise TypeError("Value for queried must be boolean.")
-        for dimension in self.dimensions:
-            if not dimension.ignore:
-                dimension.queried = value
-
     def get_errored_dimensions(self):
         for dimension in self.dimensions:
             if dimension.errored:
@@ -135,11 +140,13 @@ class Explore(LookMlObject):
     @property
     def number_of_errors(self):
         if self.errored:
-            if self.error:
-                errors = 1
+            if self.errors:
+                errors = len(self.errors)
             else:
-                errors = len(
-                    [dimension for dimension in self.dimensions if dimension.errored]
+                errors = sum(
+                    len(dimension.errors)
+                    for dimension in self.dimensions
+                    if dimension.errored
                 )
             return errors
         else:
@@ -194,6 +201,11 @@ class Model(LookMlObject):
         for explore in self.explores:
             explore.queried = value
 
+    def get_explore(self, name: str) -> Optional[Explore]:
+        return next(
+            (explore for explore in self.explores if explore.name == name), None
+        )
+
     def get_errored_explores(self):
         for explore in self.explores:
             if explore.errored:
@@ -226,6 +238,9 @@ class Project(LookMlObject):
 
         return self.name == other.name and self.models == other.models
 
+    def count_explores(self) -> int:
+        return sum(len(model.explores) for model in self.models)
+
     @property
     def errored(self):
         if self.queried:
@@ -260,17 +275,29 @@ class Project(LookMlObject):
             if model.errored:
                 yield model
 
-    def get_results(self, mode: QueryMode) -> Dict[str, Any]:
-        errors = []
+    def get_model(self, name: str) -> Optional[Model]:
+        return next((model for model in self.models if model.name == name), None)
+
+    def get_explore(self, model: str, name: str) -> Optional[Explore]:
+        model_object = self.get_model(model)
+        if not model_object:
+            return None
+        else:
+            return model_object.get_explore(name)
+
+    def get_results(
+        self, validator: str, mode: Optional[QueryMode] = None
+    ) -> Dict[str, Any]:
+        errors: List[Dict[str, Any]] = []
         tested = []
 
         def parse_explore_errors(explore):
-            if mode == "batch":
-                errors.append(explore.error.__dict__)
+            if validator != "sql" or mode == "batch":
+                errors.extend([error.__dict__ for error in explore.errors])
             else:
                 for dimension in explore.dimensions:
                     if dimension.errored:
-                        errors.append(dimension.error.__dict__)
+                        errors.extend([error.__dict__ for error in dimension.errors])
 
         for model in self.models:
             for explore in model.explores:
@@ -287,7 +314,7 @@ class Project(LookMlObject):
 
         passed = min((test["passed"] for test in tested), default=True)
         return {
-            "validator": "sql",
+            "validator": validator,
             "status": "passed" if passed else "failed",
             "tested": tested,
             "errors": errors,
diff --git a/spectacles/printer.py b/spectacles/printer.py
index 92a2a87..872bced 100644
--- a/spectacles/printer.py
+++ b/spectacles/printer.py
@@ -36,6 +36,23 @@ def print_header(text: str, line_width: int = LINE_WIDTH) -> None:
     logger.info(f"\n{header}\n")
 
 
+def print_content_error(
+    model: str,
+    explore: str,
+    message: str,
+    content_type: str,
+    space: str,
+    title: str,
+    url: str,
+):
+    path = f"{title} [{space}]"
+    message = f"Error in {model}/{explore}: {message}"
+    print_header(red(path), LINE_WIDTH + COLOR_CODE_LENGTH)
+    wrapped = textwrap.fill(message, LINE_WIDTH)
+    logger.info(wrapped)
+    logger.info("\n" + f"{content_type.title()}: {url}")
+
+
 def print_data_test_error(
     model: str, explore: str, test_name: str, message: str, lookml_url: str
 ) -> None:
diff --git a/spectacles/runner.py b/spectacles/runner.py
index f9ea6b6..3c46741 100644
--- a/spectacles/runner.py
+++ b/spectacles/runner.py
@@ -1,8 +1,10 @@
 from typing import List, Dict, Any, Optional, NamedTuple
+import itertools
 from spectacles.client import LookerClient
-from spectacles.validators import SqlValidator, DataTestValidator
-from spectacles.utils import log_duration, time_hash
+from spectacles.validators import SqlValidator, DataTestValidator, ContentValidator
+from spectacles.utils import time_hash
 from spectacles.logger import GLOBAL_LOGGER as logger
+from spectacles.printer import print_header
 from spectacles.types import QueryMode
 
 
@@ -27,15 +29,13 @@ class LookerBranchManager:
         """Context manager for Git branch checkout, creation, and deletion."""
         self.client = client
         self.project = project
+        self.commit_ref = commit_ref
         self.name = name
         self.remote_reset = remote_reset
         self.import_projects = import_projects
-        self.commit_ref = commit_ref
 
         # Get the current branch so we can return to it afterwards
         self.original_branch = self.client.get_active_branch_name(self.project)
-        # If the desired branch is master and no ref is passed, we can stay in prod
-        self.workspace = "production" if name == "master" and not commit_ref else "dev"
         self.temp_branches: List[BranchState] = []
 
     def __enter__(self):
@@ -63,10 +63,30 @@ class LookerBranchManager:
             # Tear down any temporary branches
             for project, original_branch, temp_branch in self.temp_branches:
                 self.restore_branch(project, original_branch, temp_branch)
+        self.temp_branches = []
 
         # Return to the starting branch
         self.restore_branch(self.project, self.original_branch)
 
+    @property
+    def name(self) -> str:
+        return self._name
+
+    @name.setter
+    def name(self, name: str):
+        self._name = name
+        # If the desired branch is master and no ref is passed, we can stay in prod
+        self.workspace = (
+            "production" if name == "master" and not self.commit_ref else "dev"
+        )
+
+    @property
+    def ref(self) -> str:
+        if self.commit_ref:
+            return self.commit_ref[:6]
+        else:
+            return self.name
+
     def setup_temp_branch(self, project: str, original_branch: str) -> str:
         name = "tmp_spectacles_" + time_hash()
         logger.debug(
@@ -130,7 +150,7 @@ class Runner:
         api_version: float = 3.1,
         remote_reset: bool = False,
         import_projects: bool = False,
-        commit_ref: str = None,
+        commit_ref: Optional[str] = None,
     ):
         self.project = project
         self.import_projects = import_projects
@@ -146,7 +166,6 @@ class Runner:
             commit_ref=commit_ref,
         )
 
-    @log_duration
     def validate_sql(
         self,
         selectors: List[str],
@@ -156,11 +175,21 @@ class Runner:
     ) -> Dict[str, Any]:
         with self.branch_manager:
             sql_validator = SqlValidator(self.client, self.project, concurrency)
+            logger.info(
+                "Building LookML project hierarchy for project "
+                f"'{self.project}' @ {self.branch_manager.ref}"
+            )
             sql_validator.build_project(selectors, exclusions)
+            explore_count = sql_validator.project.count_explores()
+            print_header(
+                f"Testing {explore_count} "
+                f"{'explore' if explore_count == 1 else 'explores'} "
+                f"[{mode} mode] "
+                f"[concurrency = {sql_validator.query_slots}]"
+            )
             results = sql_validator.validate(mode)
         return results
 
-    @log_duration
     def validate_data_tests(
         self, selectors: List[str], exclusions: List[str]
     ) -> Dict[str, Any]:
@@ -168,3 +197,85 @@ class Runner:
             data_test_validator = DataTestValidator(self.client, self.project)
             results = data_test_validator.validate(selectors, exclusions)
         return results
+
+    def validate_content(
+        self,
+        selectors: List[str],
+        exclusions: List[str],
+        incremental: bool = False,
+        exclude_personal: bool = False,
+    ) -> Dict[str, Any]:
+        with self.branch_manager:
+            content_validator = ContentValidator(
+                self.client, self.project, exclude_personal
+            )
+            logger.info(
+                "Building LookML project hierarchy for project "
+                f"'{self.project}' @ {self.branch_manager.ref}"
+            )
+            content_validator.build_project(selectors, exclusions)
+            explore_count = content_validator.project.count_explores()
+            print_header(
+                f"Validating content based on {explore_count} "
+                f"{'explore' if explore_count == 1 else 'explores'}"
+                + (" [incremental mode] " if incremental else "")
+            )
+            results = content_validator.validate()
+        if incremental and self.branch_manager.name != "master":
+            logger.debug("Starting another content validation against master")
+            self.branch_manager.commit_ref = None
+            self.branch_manager.name = "master"
+            with self.branch_manager:
+                logger.debug(
+                    "Building LookML project hierarchy for project "
+                    f"'{self.project}' @ {self.branch_manager.ref}"
+                )
+                content_validator.build_project(selectors, exclusions)
+                main_results = content_validator.validate()
+            return self._incremental_results(main=main_results, additional=results)
+        else:
+            return results
+
+    @staticmethod
+    def _incremental_results(
+        main: Dict[str, Any], additional: Dict[str, Any]
+    ) -> Dict[str, Any]:
+        """Returns a new result with only the additional errors in `additional`."""
+        incremental: Dict[str, Any] = {
+            "validator": "content",
+            # Start with models and explores we know passed in `additional`
+            "tested": [test for test in additional["tested"] if test["passed"]],
+            "errors": [],
+        }
+
+        # Build a list of disputed tests where dupes by model and explore are allowed
+        tests = []
+        for error in additional["errors"]:
+            if error in main["errors"]:
+                passed = True
+            else:
+                passed = False
+                incremental["errors"].append(error)
+
+            test = dict(model=error["model"], explore=error["explore"], passed=passed)
+            tests.append(test)
+
+        def key_by(x):
+            return (x["model"], x["explore"])
+
+        if tests:
+            # Dedupe the list of tests, grouping by model and explore and taking the min
+            # To do this, we group by model and explore and sort by `passed`
+            tests = sorted(tests, key=lambda x: (x["model"], x["explore"], x["passed"]))
+            for key, group in itertools.groupby(tests, key=key_by):
+                items = list(group)
+                incremental["tested"].append(items[0])
+
+        # Re-sort the final list
+        incremental["tested"] = sorted(incremental["tested"], key=key_by)
+
+        # Recompute the overall state of the test suite
+        passed = min((test["passed"] for test in incremental["tested"]), default=True)
+        incremental["status"] = "passed" if passed else "failed"
+
+        return incremental
diff --git a/spectacles/utils.py b/spectacles/utils.py
index e7b3eee..739e8fe 100644
--- a/spectacles/utils.py
+++ b/spectacles/utils.py
@@ -34,7 +34,11 @@ def human_readable(elapsed: int):
 
 
 def get_detail(fn_name: str):
-    detail_map = {"validate_sql": "SQL ", "validate_data_tests": "test "}
+    detail_map = {
+        "run_sql": "SQL ",
+        "run_assert": "data test ",
+        "run_content": "content ",
+    }
     return detail_map.get(fn_name, "")
 
 
@@ -43,12 +47,14 @@ def log_duration(fn: Callable):
 
     def timed_function(*args, **kwargs):
         start_time = timeit.default_timer()
-        result = fn(*args, **kwargs)
-        elapsed = timeit.default_timer() - start_time
-        elapsed_str = human_readable(elapsed)
-        message_detail = get_detail(fn.__name__)
-
-        logger.info(f"\nCompleted {message_detail}validation in {elapsed_str}.")
+        try:
+            result = fn(*args, **kwargs)
+        finally:
+            elapsed = timeit.default_timer() - start_time
+            elapsed_str = human_readable(elapsed)
+            message_detail = get_detail(fn.__name__)
+
+            logger.info(f"Completed {message_detail}validation in {elapsed_str}.\n")
         return result
 
     return timed_function
diff --git a/spectacles/validators/__init__.py b/spectacles/validators/__init__.py
new file mode 100644
index 0000000..40dc7e5
--- /dev/null
+++ b/spectacles/validators/__init__.py
@@ -0,0 +1,5 @@
+from spectacles.validators.sql import SqlValidator
+from spectacles.validators.data_test import DataTestValidator
+from spectacles.validators.content import ContentValidator
+
+__all__ = ["SqlValidator", "DataTestValidator", "ContentValidator"]
diff --git a/spectacles/validators/content.py b/spectacles/validators/content.py
new file mode 100644
index 0000000..40af702
--- /dev/null
+++ b/spectacles/validators/content.py
@@ -0,0 +1,132 @@
+from typing import List, Optional, Any, Dict
+from spectacles.validators.validator import Validator
+from spectacles.client import LookerClient
+from spectacles.lookml import Project, Model
+from spectacles.exceptions import LookMlNotFound, ContentError
+from spectacles.select import is_selected
+from spectacles.lookml import Explore
+from spectacles.logger import GLOBAL_LOGGER as logger
+
+
+class ContentValidator(Validator):
+    def __init__(
+        self, client: LookerClient, project: str, exclude_personal: bool = False
+    ):
+        super().__init__(client)
+        self.project = Project(project, models=[])
+        personal_folders = self._get_personal_folders() if exclude_personal else []
+        self.personal_folders: List[int] = personal_folders
+
+    def _get_personal_folders(self) -> List[int]:
+        personal_folders = []
+        result = self.client.all_folders(self.project.name)
+        for folder in result:
+            if folder["is_personal"] or folder["is_personal_descendant"]:
+                personal_folders.append(folder["id"])
+        return personal_folders
+
+    def build_project(
+        self,
+        selectors: Optional[List[str]] = None,
+        exclusions: Optional[List[str]] = None,
+    ) -> None:
+        """Creates an object representation of the project's LookML.
+
+        Args:
+            selectors: List of selector strings in 'model_name/explore_name' format.
+                The '*' wildcard selects all models or explores. For instance,
+                'model_name/*' would select all explores in the 'model_name' model.
+
+        """
+        # Assign default values for selectors and exclusions
+        if selectors is None:
+            selectors = ["*/*"]
+        if exclusions is None:
+            exclusions = []
+
+        all_models = [
+            Model.from_json(model) for model in self.client.get_lookml_models()
+        ]
+        project_models = [
+            model for model in all_models if model.project_name == self.project.name
+        ]
+
+        if not project_models:
+            raise LookMlNotFound(
+                name="project-models-not-found",
+                title="No configured models found for the specified project.",
+                detail=(
+                    f"Go to {self.client.base_url}/projects and confirm "
+                    "a) at least one model exists for the project and "
+                    "b) it has an active configuration."
+                ),
+            )
+
+        for model in project_models:
+            model.explores = [
+                explore
+                for explore in model.explores
+                if is_selected(model.name, explore.name, selectors, exclusions)
+            ]
+
+        self.project.models = [
+            model for model in project_models if len(model.explores) > 0
+        ]
+
+    def validate(self) -> Dict[str, Any]:
+        result = self.client.content_validation()
+        self.project.queried = True
+
+        for content in result["content_with_errors"]:
+            # Skip content dicts if they lack a `look` or `dashboard` key
+            try:
+                content_type = self._get_content_type(content)
+            except KeyError:
+                logger.warn(
+                    "Warning: Skipping some content because it does not seem to be a "
+                    "Dashboard or a Look."
+                )
+                logger.debug(f"The unidentified content received was: {content}")
+                continue
+
+            # If exclude_personal isn't specified, personal_folders list is empty
+            if content[content_type]["folder"]["id"] in self.personal_folders:
+                continue
+            else:
+                self._handle_content_result(content, content_type)
+
+        return self.project.get_results(validator="content")
+
+    @staticmethod
+    def _get_content_type(content: Dict[str, Any]) -> str:
+        if content["dashboard"]:
+            return "dashboard"
+        elif content["look"]:
+            return "look"
+        else:
+            raise KeyError("Content type not found. Valid keys are 'look', 'dashboard'")
+
+    def _handle_content_result(self, content: Dict, content_type: str) -> None:
+        for error in content["errors"]:
+            model_name = error["model_name"]
+            explore_name = error["explore_name"]
+            explore: Optional[Explore] = self.project.get_explore(
+                model=model_name, name=explore_name
+            )
+            # Skip errors that are not associated with selected explores
+            if explore is None:
+                continue
+
+            content_id = content[content_type]["id"]
+            content_error = ContentError(
+                model=model_name,
+                explore=explore_name,
+                message=error["message"],
+                field_name=error["field_name"],
+                content_type=content_type,
+                title=content[content_type]["title"],
+                space=content[content_type]["space"]["name"],
+                url=f"{self.client.base_url}/{content_type}s/{content_id}",
+            )
+            if content_error not in explore.errors:
+                explore.errors.append(content_error)
diff --git a/spectacles/validators.py b/spectacles/validators/sql.py
similarity index 68%
rename from spectacles/validators.py
rename to spectacles/validators/sql.py
index 6627cf5..8d41741 100644
--- a/spectacles/validators.py
+++ b/spectacles/validators/sql.py
@@ -1,19 +1,12 @@
+from typing import Union, Dict, Any, List, Optional
 import time
-from typing import Any, List, Dict, Union, Optional
-from abc import ABC, abstractmethod
-from collections import OrderedDict
+from spectacles.validators.validator import Validator
 from spectacles.client import LookerClient
-from spectacles.lookml import Project, Model, Explore, Dimension
+from spectacles.lookml import Project, Model, Dimension, Explore
 from spectacles.types import QueryMode
+from spectacles.exceptions import SpectaclesException, SqlError, LookMlNotFound
 from spectacles.select import is_selected
 from spectacles.logger import GLOBAL_LOGGER as logger
-from spectacles.exceptions import (
-    SqlError,
-    DataTestError,
-    SpectaclesException,
-    LookMlNotFound,
-)
-import spectacles.printer as printer
 
 
 class Query:
@@ -43,141 +36,6 @@ class QueryResult:
         self.error = error
 
 
-class Validator(ABC):  # pragma: no cover
-    """Defines abstract base interface for validators.
-
-    Not intended to be used directly, only inherited.
-
-    Attributes:
-        client: Looker API client.
-
-    """
-
-    def __init__(self, client: LookerClient):
-        self.client = client
-
-    @abstractmethod
-    def validate(self):
-        raise NotImplementedError
-
-
-class DataTestValidator(Validator):
-    """Runs LookML/data tests for a given project.
-
-    Args:
-        client: Looker API client.
-        project: Name of the LookML project to validate.
-
-    """
-
-    def __init__(self, client: LookerClient, project: str):
-        super().__init__(client)
-        self.project = project
-
-    def validate(
-        self,
-        selectors: Optional[List[str]] = None,
-        exclusions: Optional[List[str]] = None,
-    ) -> Dict[str, Any]:
-        # Assign default values for selectors and exclusions
-        if selectors is None:
-            selectors = ["*/*"]
-        if exclusions is None:
-            exclusions = []
-
-        all_tests = self.client.all_lookml_tests(self.project)
-        selected_tests = []
-        test_to_explore = {}
-        for test in all_tests:
-            if is_selected(
-                test["model_name"], test["explore_name"], selectors, exclusions
-            ):
-                selected_tests.append(test)
-                # The error objects don't contain the name of the explore
-                # We create this mapping to help look up the explore from the test name
-                test_to_explore[test["name"]] = test["explore_name"]
-
-        test_count = len(selected_tests)
-        if test_count == 0:
-            raise SpectaclesException(
-                name="no-data-tests-found",
-                title="No data tests found.",
-                detail=(
-                    "If you're using --explores or --exclude, make sure your project "
-                    "has data tests that reference those models or explores."
-                ),
-            )
-
-        printer.print_header(
-            f"Running {test_count} {'test' if test_count == 1 else 'tests'}"
-        )
-
-        test_results: List[Dict[str, Any]] = []
-        for test in selected_tests:
-            test_name = test["name"]
-            model_name = test["model_name"]
-            results = self.client.run_lookml_test(
-                self.project, model=model_name, test=test_name
-            )
-            test_results.extend(results)
-
-        tested = []
-        errors = []
-
-        for result in test_results:
-            explore = test_to_explore[result["test_name"]]
-            test = {
-                "model": result["model_name"],
-                "explore": explore,
-                "passed": result["success"],
-            }
-            tested.append(test)
-            if not result["success"]:
-                for error in result["errors"]:
-                    project, file_path = error["file_path"].split("/", 1)
-                    lookml_url = (
-                        f"{self.client.base_url}/projects/{self.project}"
-                        f"/files/{file_path}?line={error['line_number']}"
-                    )
-                    errors.append(
-                        DataTestError(
-                            model=error["model_id"],
-                            explore=error["explore"],
-                            message=error["message"],
-                            test_name=result["test_name"],
-                            lookml_url=lookml_url,
-                        ).__dict__
-                    )
-
-        def reduce_result(results):
-            """Aggregate individual test results to get pass/fail by explore"""
-            agg = OrderedDict()
-            for result in results:
-                # Keys by model and explore, adds additional values for `passed` to a set
-                agg.setdefault((result["model"], result["explore"]), set()).add(
-                    result["passed"]
-                )
-            reduced = [
-                {"model": k[0], "explore": k[1], "passed": min(v)}
-                for k, v in agg.items()
-            ]
-            return reduced
-
-        tested = reduce_result(tested)
-        for test in tested:
-            printer.print_validation_result(
-                passed=test["passed"], source=f"{test['model']}.{test['explore']}"
-            )
-
-        passed = min((test["passed"] for test in tested), default=True)
-        return {
-            "validator": "data_test",
-            "status": "passed" if passed else "failed",
-            "tested": tested,
-            "errors": errors,
-        }
-
-
 class SqlValidator(Validator):
     """Runs and validates the SQL for each selected LookML dimension.
 
@@ -229,10 +87,6 @@ class SqlValidator(Validator):
         if exclusions is None:
             exclusions = []
 
-        logger.info(
-            f"Building LookML project hierarchy for project {self.project.name}"
-        )
-
         all_models = [
             Model.from_json(model) for model in self.client.get_lookml_models()
         ]
@@ -243,7 +97,7 @@ class SqlValidator(Validator):
         if not project_models:
             raise LookMlNotFound(
                 name="project-models-not-found",
-                title="No matching models found for the specified project and selectors.",
+                title="No configured models found for the specified project.",
                 detail=(
                     f"Go to {self.client.base_url}/projects and confirm "
                     "a) at least one model exists for the project and "
@@ -276,26 +130,12 @@ class SqlValidator(Validator):
     def validate(self, mode: QueryMode = "batch") -> Dict[str, Any]:
         """Queries selected explores and returns the project tree with errors."""
         self._query_by_task_id = {}
-        explore_count = self._count_explores()
-        printer.print_header(
-            f"Testing {explore_count} "
-            f"{'explore' if explore_count == 1 else 'explores'} "
-            f"[{mode} mode] "
-            f"[concurrency = {self.query_slots}]"
-        )
 
         self._create_and_run(mode)
         if mode == "hybrid" and self.project.errored:
             self._create_and_run(mode)
 
-        for model in sorted(self.project.models, key=lambda x: x.name):
-            for explore in sorted(model.explores, key=lambda x: x.name):
-                message = f"{model.name}.{explore.name}"
-                printer.print_validation_result(
-                    passed=not explore.errored, source=message
-                )
-
-        return self.project.get_results(mode)
+        return self.project.get_results(validator="sql", mode=mode)
 
     def _create_and_run(self, mode: QueryMode = "batch") -> None:
         """Runs a single validation using a specified mode"""
@@ -444,7 +284,7 @@ class SqlValidator(Validator):
                     lookml_url=getattr(lookml_object, "url", None),
                     **result.error,
                 )
-                lookml_object.error = sql_error
+                lookml_object.errors.append(sql_error)
                 return sql_error
         return None
 
@@ -493,10 +333,3 @@ class SqlValidator(Validator):
         """Asks the Looker API to cancel specified queries"""
         for query_task_id in query_task_ids:
             self.client.cancel_query_task(query_task_id)
-
-    def _count_explores(self) -> int:
-        """Counts the explores in the LookML project hierarchy."""
-        explore_count = 0
-        for model in self.project.models:
-            explore_count += len(model.explores)
-        return explore_count
diff --git a/spectacles/validators/validator.py b/spectacles/validators/validator.py
new file mode 100644
index 0000000..fd9f322
--- /dev/null
+++ b/spectacles/validators/validator.py
@@ -0,0 +1,20 @@
+from abc import ABC, abstractmethod
+from spectacles.client import LookerClient
+
+
+class Validator(ABC):  # pragma: no cover
+    """Defines abstract base interface for validators.
+
+    Not intended to be used directly, only inherited.
+
+    Attributes:
+        client: Looker API client.
+
+    """
+
+    def __init__(self, client: LookerClient):
+        self.client = client
+
+    @abstractmethod
+    def validate(self):
+        raise NotImplementedError


Test patch:
diff --git a/spectacles/validators/data_test.py b/spectacles/validators/data_test.py
new file mode 100644
index 0000000..73bb357
--- /dev/null
+++ b/spectacles/validators/data_test.py
@@ -0,0 +1,124 @@
+from typing import Optional, List, Dict, Any
+from collections import OrderedDict
+from spectacles.client import LookerClient
+from spectacles.validators.validator import Validator
+from spectacles.select import is_selected
+from spectacles.exceptions import SpectaclesException, DataTestError
+import spectacles.printer as printer
+
+
+class DataTestValidator(Validator):
+    """Runs LookML/data tests for a given project.
+
+    Args:
+        client: Looker API client.
+        project: Name of the LookML project to validate.
+
+    """
+
+    def __init__(self, client: LookerClient, project: str):
+        super().__init__(client)
+        self.project = project
+
+    def validate(
+        self,
+        selectors: Optional[List[str]] = None,
+        exclusions: Optional[List[str]] = None,
+    ) -> Dict[str, Any]:
+        # Assign default values for selectors and exclusions
+        if selectors is None:
+            selectors = ["*/*"]
+        if exclusions is None:
+            exclusions = []
+
+        all_tests = self.client.all_lookml_tests(self.project)
+        selected_tests = []
+        test_to_explore = {}
+        for test in all_tests:
+            if is_selected(
+                test["model_name"], test["explore_name"], selectors, exclusions
+            ):
+                selected_tests.append(test)
+                # The error objects don't contain the name of the explore
+                # We create this mapping to help look up the explore from the test name
+                test_to_explore[test["name"]] = test["explore_name"]
+
+        test_count = len(selected_tests)
+        if test_count == 0:
+            raise SpectaclesException(
+                name="no-data-tests-found",
+                title="No data tests found.",
+                detail=(
+                    "If you're using --explores or --exclude, make sure your project "
+                    "has data tests that reference those models or explores."
+                ),
+            )
+
+        printer.print_header(
+            f"Running {test_count} {'test' if test_count == 1 else 'tests'}"
+        )
+
+        test_results: List[Dict[str, Any]] = []
+        for test in selected_tests:
+            test_name = test["name"]
+            model_name = test["model_name"]
+            results = self.client.run_lookml_test(
+                self.project, model=model_name, test=test_name
+            )
+            test_results.extend(results)
+
+        tested = []
+        errors = []
+
+        for result in test_results:
+            explore = test_to_explore[result["test_name"]]
+            test = {
+                "model": result["model_name"],
+                "explore": explore,
+                "passed": result["success"],
+            }
+            tested.append(test)
+            if not result["success"]:
+                for error in result["errors"]:
+                    project, file_path = error["file_path"].split("/", 1)
+                    lookml_url = (
+                        f"{self.client.base_url}/projects/{self.project}"
+                        f"/files/{file_path}?line={error['line_number']}"
+                    )
+                    errors.append(
+                        DataTestError(
+                            model=error["model_id"],
+                            explore=error["explore"],
+                            message=error["message"],
+                            test_name=result["test_name"],
+                            lookml_url=lookml_url,
+                        ).__dict__
+                    )
+
+        def reduce_result(results):
+            """Aggregate individual test results to get pass/fail by explore"""
+            agg = OrderedDict()
+            for result in results:
+                # Keys by model and explore, adds additional values for `passed` to a set
+                agg.setdefault((result["model"], result["explore"]), set()).add(
+                    result["passed"]
+                )
+            reduced = [
+                {"model": k[0], "explore": k[1], "passed": min(v)}
+                for k, v in agg.items()
+            ]
+            return reduced
+
+        tested = reduce_result(tested)
+        for test in tested:
+            printer.print_validation_result(
+                passed=test["passed"], source=f"{test['model']}.{test['explore']}"
+            )
+
+        passed = min((test["passed"] for test in tested), default=True)
+        return {
+            "validator": "data_test",
+            "status": "passed" if passed else "failed",
+            "tested": tested,
+            "errors": errors,
+        }
diff --git a/tests/cassettes/test_content_validator/fixture_validator_fail.yaml b/tests/cassettes/test_content_validator/fixture_validator_fail.yaml
new file mode 100644
index 0000000..c0ae736
--- /dev/null
+++ b/tests/cassettes/test_content_validator/fixture_validator_fail.yaml
@@ -0,0 +1,121 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Cookie:
+      - looker.browser=5144
+    method: GET
+    uri: https://spectacles.looker.com:19999/api/3.1/lookml_models
+  response:
+    body:
+      string: '[{"explores":[{"description":null,"label":"Cohort Data Tool","hidden":true,"group_label":"1)
+        eCommerce with Event Data","name":"cohorts","can":{}},{"description":null,"label":"Web
+        Analytics Data Tool","hidden":true,"group_label":"1) eCommerce with Event
+        Data","name":"data_tool","can":{}},{"description":null,"label":"(1) Orders,
+        Items and Users","hidden":false,"group_label":"1) eCommerce with Event Data","name":"order_items","can":{}},{"description":null,"label":"(2)
+        Web Event Data","hidden":false,"group_label":"1) eCommerce with Event Data","name":"events","can":{}},{"description":null,"label":"(3)
+        Web Session Data","hidden":false,"group_label":"1) eCommerce with Event Data","name":"sessions","can":{}},{"description":null,"label":"(4)
+        Affinity Analysis","hidden":false,"group_label":"1) eCommerce with Event Data","name":"affinity","can":{}},{"description":null,"label":"(5)
+        Share of Wallet Analysis","hidden":false,"group_label":"1) eCommerce with
+        Event Data","name":"orders_with_share_of_wallet_application","can":{}},{"description":null,"label":"(6)
+        Customer Journey Mapping","hidden":false,"group_label":"1) eCommerce with
+        Event Data","name":"journey_mapping","can":{}},{"description":null,"label":"(7)
+        Stock Analysis","hidden":false,"group_label":"1) eCommerce with Event Data","name":"inventory_items","can":{}},{"description":null,"label":"(8)
+        Historical Stock Snapshot Analysis","hidden":false,"group_label":"1) eCommerce
+        with Event Data","name":"inventory_snapshot","can":{}},{"description":null,"label":"Order
+        Items (Kittens)","hidden":true,"group_label":"1) eCommerce with Event Data","name":"kitten_order_items","can":{}}],"has_content":true,"label":"1)
+        eCommerce with Event Data","name":"thelook","project_name":"welcome_to_looker","unlimited_db_connections":false,"allowed_db_connection_names":["snowlooker"],"can":{"index":true,"show":true,"create":true,"update":true,"destroy":true}},{"explores":[{"description":null,"label":"Orders","hidden":false,"group_label":"Jaffle
+        Shop","name":"fct_orders","can":{}}],"has_content":true,"label":"Jaffle Shop","name":"jaffle_shop","project_name":"jaffle_shop","unlimited_db_connections":false,"allowed_db_connection_names":["bigquery"],"can":{"index":true,"show":true,"create":true,"update":true,"destroy":true}},{"explores":[],"has_content":false,"label":"Admin","name":"admin","project_name":"jaffle_shop","unlimited_db_connections":false,"allowed_db_connection_names":["bigquery"],"can":{"index":true,"show":true,"create":true,"update":true,"destroy":true}},{"explores":[{"description":null,"label":"Users","hidden":false,"group_label":"Eye
+        Exam","name":"users","can":{}},{"description":null,"label":"Users Fail","hidden":false,"group_label":"Eye
+        Exam","name":"users__fail","can":{}},{"description":null,"label":"Users Fail
+        and Warn","hidden":false,"group_label":"Eye Exam","name":"users__fail_and_warn","can":{}},{"description":null,"label":"Users
+        Warn","hidden":false,"group_label":"Eye Exam","name":"users__warn","can":{}}],"has_content":true,"label":"Eye
+        Exam","name":"eye_exam","project_name":"eye_exam","unlimited_db_connections":false,"allowed_db_connection_names":["snowlooker"],"can":{"index":true,"show":true,"create":true,"update":true,"destroy":true}},{"explores":[{"description":null,"label":"Content
+        Usage","hidden":false,"group_label":"System Activity","name":"content_usage","can":{}},{"description":null,"label":"Dashboard","hidden":false,"group_label":"System
+        Activity","name":"dashboard","can":{}},{"description":null,"label":"Dashboard
+        Performance","hidden":false,"group_label":"System Activity","name":"dashboard_performance","can":{}},{"description":null,"label":"DB
+        Connection","hidden":false,"group_label":"System Activity","name":"db_connection","can":{}},{"description":null,"label":"Event","hidden":false,"group_label":"System
+        Activity","name":"event","can":{}},{"description":null,"label":"Event Attribute","hidden":false,"group_label":"System
+        Activity","name":"event_attribute","can":{}},{"description":null,"label":"Field
+        Usage","hidden":false,"group_label":"System Activity","name":"field_usage","can":{}},{"description":null,"label":"Group","hidden":false,"group_label":"System
+        Activity","name":"group","can":{}},{"description":null,"label":"History","hidden":false,"group_label":"System
+        Activity","name":"history","can":{}},{"description":null,"label":"Look","hidden":false,"group_label":"System
+        Activity","name":"look","can":{}},{"description":null,"label":"Merge Query","hidden":false,"group_label":"System
+        Activity","name":"merge_query","can":{}},{"description":null,"label":"PDT
+        Event Log","hidden":false,"group_label":"System Activity","name":"pdt_event_log","can":{}},{"description":null,"label":"PDT
+        Builds","hidden":false,"group_label":"System Activity","name":"pdt_builds","can":{}},{"description":null,"label":"Result
+        Maker Lookup","hidden":false,"group_label":"System Activity","name":"result_maker_lookup","can":{}},{"description":null,"label":"Scheduled
+        Plan","hidden":false,"group_label":"System Activity","name":"scheduled_plan","can":{}},{"description":null,"label":"Space","hidden":false,"group_label":"System
+        Activity","name":"space","can":{}},{"description":null,"label":"SQL Query","hidden":false,"group_label":"System
+        Activity","name":"sql_query","can":{}},{"description":null,"label":"User","hidden":false,"group_label":"System
+        Activity","name":"user","can":{}},{"description":null,"label":"Role","hidden":false,"group_label":"System
+        Activity","name":"role","can":{}}],"has_content":true,"label":"System Activity","name":"system__activity","project_name":"system__activity","unlimited_db_connections":false,"allowed_db_connection_names":[],"can":{"index":true,"show":true,"create":true,"update":true,"destroy":true}}]'
+    headers:
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '5740'
+      Content-Type:
+      - application/json
+      Date:
+      - Sat, 20 Jun 2020 16:31:39 GMT
+      Server:
+      - nginx
+      Vary:
+      - Accept-Encoding
+      - Accept-Encoding
+      X-Content-Type-Options:
+      - nosniff
+    status:
+      code: 200
+      message: OK
+- request:
+    body: null
+    headers:
+      Cookie:
+      - looker.browser=5144
+    method: GET
+    uri: https://spectacles.looker.com:19999/api/3.1/content_validation
+  response:
+    body:
+      string: '{"content_with_errors":[{"look":null,"dashboard":{"description":"","id":1,"title":"Business
+        Pulse","space":{"name":"Shared","id":1},"folder":{"name":"Shared","id":1}},"dashboard_element":{"body_text":null,"dashboard_id":1,"id":2,"look_id":null,"note_display":"below","note_state":"collapsed","note_text":"","note_text_as_html":"","query_id":null,"subtitle_text":null,"title":"Average
+        Order Sale Price","title_hidden":false,"title_text":null,"type":"vis"},"dashboard_filter":null,"scheduled_plan":null,"errors":[{"message":"Unknown
+        field \"orders.average_profit\".","field_name":"orders.average_profit","model_name":"thelook","explore_name":"order_items","removable":true}],"id":0},{"look":null,"dashboard":{"description":"","id":1,"title":"Business
+        Pulse","space":{"name":"Shared","id":1},"folder":{"name":"Shared","id":1}},"dashboard_element":{"body_text":null,"dashboard_id":1,"id":12,"look_id":null,"note_display":"hover","note_state":"collapsed","note_text":"What
+        percent of orders are followed by a repeat purchase by the same user within
+        30 days?","note_text_as_html":"What percent of orders are followed by a repeat
+        purchase by the same user within 30 days?","query_id":null,"subtitle_text":null,"title":"30
+        Day Repeat Purchase Rate","title_hidden":false,"title_text":null,"type":"vis"},"dashboard_filter":null,"scheduled_plan":null,"errors":[{"message":"Unknown
+        field \"repeat_purchase_facts.30_day_repeat_purchase_rate\".","field_name":"repeat_purchase_facts.30_day_repeat_purchase_rate","model_name":"thelook","explore_name":"order_items","removable":true}],"id":1},{"look":null,"dashboard":{"description":null,"id":2,"title":"Brand
+        Analytics, Web \u0026 Transactional","space":{"name":"Shared","id":1},"folder":{"name":"Shared","id":1}},"dashboard_element":{"body_text":null,"dashboard_id":2,"id":24,"look_id":null,"note_display":null,"note_state":null,"note_text":null,"note_text_as_html":null,"query_id":null,"subtitle_text":null,"title":"Brand
+        Share of Wallet over Customer Lifetime","title_hidden":false,"title_text":null,"type":"vis"},"dashboard_filter":null,"scheduled_plan":null,"errors":[{"message":"Unknown
+        field \"order_items.share_of_wallet_brand_within_company\".","field_name":"order_items.share_of_wallet_brand_within_company","model_name":"thelook","explore_name":"orders_with_share_of_wallet_application","removable":true},{"message":"Unknown
+        field \"order_items.item_comparison\".","field_name":"order_items.item_comparison","model_name":"thelook","explore_name":"orders_with_share_of_wallet_application","removable":true}],"id":2},{"look":{"id":7,"title":"Top
+        10 Users by State [fail]","space":{"name":"pytest","id":25},"folder":{"name":"pytest","id":25}},"dashboard":null,"dashboard_element":null,"dashboard_filter":null,"scheduled_plan":null,"errors":[{"message":"Unknown
+        field \"users__fail.state\".","field_name":"users__fail.state","model_name":"eye_exam","explore_name":"users__fail","removable":true}],"id":3},{"look":{"id":8,"title":"Top
+        10 Users by State [fail]","space":{"name":"Josh Temple","id":8},"folder":{"name":"Josh
+        Temple","id":8}},"dashboard":null,"dashboard_element":null,"dashboard_filter":null,"scheduled_plan":null,"errors":[{"message":"Unknown
+        field \"users__fail.state\".","field_name":"users__fail.state","model_name":"eye_exam","explore_name":"users__fail","removable":true}],"id":4},{"look":{"id":9,"title":"Top
+        10 Users by State [fail]","space":{"name":"pytest","id":26},"folder":{"name":"pytest","id":26}},"dashboard":null,"dashboard_element":null,"dashboard_filter":null,"scheduled_plan":null,"errors":[{"message":"Unknown
+        field \"users__fail.state\".","field_name":"users__fail.state","model_name":"eye_exam","explore_name":"users__fail","removable":true}],"id":5}],"computation_time":0.111656,"total_looks_validated":4,"total_dashboard_elements_validated":34,"total_dashboard_filters_validated":5,"total_scheduled_plans_validated":0,"total_explores_validated":8}'
+    headers:
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '3924'
+      Content-Type:
+      - application/json
+      Date:
+      - Sat, 20 Jun 2020 16:31:39 GMT
+      Server:
+      - nginx
+      Vary:
+      - Accept-Encoding
+      - Accept-Encoding
+      X-Content-Type-Options:
+      - nosniff
+    status:
+      code: 200
+      message: OK
+version: 1
diff --git a/tests/cassettes/test_content_validator/fixture_validator_init.yaml b/tests/cassettes/test_content_validator/fixture_validator_init.yaml
new file mode 100644
index 0000000..7859afc
--- /dev/null
+++ b/tests/cassettes/test_content_validator/fixture_validator_init.yaml
@@ -0,0 +1,126 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Cookie:
+      - looker.browser=5144
+    method: GET
+    uri: https://spectacles.looker.com:19999/api/3.1/folders
+  response:
+    body:
+      string: '[{"name":"Shared","parent_id":null,"id":1,"content_metadata_id":1,"created_at":"2019-11-16T02:18:31.829+00:00","creator_id":null,"child_count":1,"external_id":null,"is_embed":false,"is_embed_shared_root":false,"is_embed_users_root":false,"is_personal":false,"is_personal_descendant":false,"is_shared_root":true,"is_users_root":false,"can":{"index":true,"show":true,"create":true,"see_admin_spaces":true,"update":false,"destroy":false,"move_content":true,"edit_content":true}},{"name":"Users","parent_id":null,"id":2,"content_metadata_id":2,"created_at":"2019-11-16T02:18:31.832+00:00","creator_id":null,"child_count":7,"external_id":null,"is_embed":false,"is_embed_shared_root":false,"is_embed_users_root":false,"is_personal":false,"is_personal_descendant":false,"is_shared_root":false,"is_users_root":true,"can":{"index":true,"show":true,"create":true,"see_admin_spaces":true,"update":false,"destroy":false,"move_content":true,"edit_content":true}},{"name":"pytest","parent_id":8,"id":25,"content_metadata_id":31,"created_at":"2020-06-16T02:31:52.600+00:00","creator_id":3,"child_count":0,"external_id":null,"is_embed":false,"is_embed_shared_root":false,"is_embed_users_root":false,"is_personal":false,"is_personal_descendant":true,"is_shared_root":false,"is_users_root":false,"can":{"index":true,"show":true,"create":true,"see_admin_spaces":true,"update":true,"destroy":true,"move_content":true,"edit_content":true}},{"name":"pytest","parent_id":1,"id":26,"content_metadata_id":36,"created_at":"2020-06-20T15:06:58.344+00:00","creator_id":3,"child_count":0,"external_id":null,"is_embed":false,"is_embed_shared_root":false,"is_embed_users_root":false,"is_personal":false,"is_personal_descendant":false,"is_shared_root":false,"is_users_root":false,"can":{"index":true,"show":true,"create":true,"see_admin_spaces":true,"update":true,"destroy":true,"move_content":true,"edit_content":true}},{"name":"Josh
+        Temple","parent_id":2,"id":8,"content_metadata_id":12,"created_at":"2019-11-19T16:26:33.485+00:00","creator_id":3,"child_count":1,"external_id":null,"is_embed":false,"is_embed_shared_root":false,"is_embed_users_root":false,"is_personal":true,"is_personal_descendant":false,"is_shared_root":false,"is_users_root":false,"can":{"index":true,"show":true,"create":true,"see_admin_spaces":true,"update":false,"destroy":false,"move_content":true,"edit_content":true}},{"name":"LookML
+        Dashboards","parent_id":null,"id":"lookml","content_metadata_id":null,"created_at":null,"creator_id":null,"child_count":0,"external_id":null,"is_embed":false,"is_embed_shared_root":false,"is_embed_users_root":false,"is_personal":false,"is_personal_descendant":false,"is_shared_root":false,"is_users_root":false,"can":{"index":true,"show":true,"create":true,"see_admin_spaces":true,"update":false,"destroy":false,"move_content":false,"edit_content":false}}]'
+    headers:
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '2836'
+      Content-Type:
+      - application/json
+      Date:
+      - Sat, 20 Jun 2020 16:31:37 GMT
+      Server:
+      - nginx
+      Vary:
+      - Accept-Encoding
+      - Accept-Encoding
+      X-Content-Type-Options:
+      - nosniff
+    status:
+      code: 200
+      message: OK
+- request:
+    body: null
+    headers:
+      Cookie:
+      - looker.browser=5144
+    method: GET
+    uri: https://spectacles.looker.com:19999/api/3.1/folders
+  response:
+    body:
+      string: '[{"name":"Shared","parent_id":null,"id":1,"content_metadata_id":1,"created_at":"2019-11-16T02:18:31.829+00:00","creator_id":null,"child_count":1,"external_id":null,"is_embed":false,"is_embed_shared_root":false,"is_embed_users_root":false,"is_personal":false,"is_personal_descendant":false,"is_shared_root":true,"is_users_root":false,"can":{"index":true,"show":true,"create":true,"see_admin_spaces":true,"update":false,"destroy":false,"move_content":true,"edit_content":true}},{"name":"Users","parent_id":null,"id":2,"content_metadata_id":2,"created_at":"2019-11-16T02:18:31.832+00:00","creator_id":null,"child_count":7,"external_id":null,"is_embed":false,"is_embed_shared_root":false,"is_embed_users_root":false,"is_personal":false,"is_personal_descendant":false,"is_shared_root":false,"is_users_root":true,"can":{"index":true,"show":true,"create":true,"see_admin_spaces":true,"update":false,"destroy":false,"move_content":true,"edit_content":true}},{"name":"pytest","parent_id":8,"id":25,"content_metadata_id":31,"created_at":"2020-06-16T02:31:52.600+00:00","creator_id":3,"child_count":0,"external_id":null,"is_embed":false,"is_embed_shared_root":false,"is_embed_users_root":false,"is_personal":false,"is_personal_descendant":true,"is_shared_root":false,"is_users_root":false,"can":{"index":true,"show":true,"create":true,"see_admin_spaces":true,"update":true,"destroy":true,"move_content":true,"edit_content":true}},{"name":"pytest","parent_id":1,"id":26,"content_metadata_id":36,"created_at":"2020-06-20T15:06:58.344+00:00","creator_id":3,"child_count":0,"external_id":null,"is_embed":false,"is_embed_shared_root":false,"is_embed_users_root":false,"is_personal":false,"is_personal_descendant":false,"is_shared_root":false,"is_users_root":false,"can":{"index":true,"show":true,"create":true,"see_admin_spaces":true,"update":true,"destroy":true,"move_content":true,"edit_content":true}},{"name":"Josh
+        Temple","parent_id":2,"id":8,"content_metadata_id":12,"created_at":"2019-11-19T16:26:33.485+00:00","creator_id":3,"child_count":1,"external_id":null,"is_embed":false,"is_embed_shared_root":false,"is_embed_users_root":false,"is_personal":true,"is_personal_descendant":false,"is_shared_root":false,"is_users_root":false,"can":{"index":true,"show":true,"create":true,"see_admin_spaces":true,"update":false,"destroy":false,"move_content":true,"edit_content":true}},{"name":"LookML
+        Dashboards","parent_id":null,"id":"lookml","content_metadata_id":null,"created_at":null,"creator_id":null,"child_count":0,"external_id":null,"is_embed":false,"is_embed_shared_root":false,"is_embed_users_root":false,"is_personal":false,"is_personal_descendant":false,"is_shared_root":false,"is_users_root":false,"can":{"index":true,"show":true,"create":true,"see_admin_spaces":true,"update":false,"destroy":false,"move_content":false,"edit_content":false}}]'
+    headers:
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '2836'
+      Content-Type:
+      - application/json
+      Date:
+      - Sat, 20 Jun 2020 16:31:37 GMT
+      Server:
+      - nginx
+      Vary:
+      - Accept-Encoding
+      - Accept-Encoding
+      X-Content-Type-Options:
+      - nosniff
+    status:
+      code: 200
+      message: OK
+- request:
+    body: null
+    headers:
+      Cookie:
+      - looker.browser=5144
+    method: GET
+    uri: https://spectacles.looker.com:19999/api/3.1/folders
+  response:
+    body:
+      string: '[{"name":"Shared","parent_id":null,"id":1,"content_metadata_id":1,"created_at":"2019-11-16T02:18:31.829+00:00","creator_id":null,"child_count":1,"external_id":null,"is_embed":false,"is_embed_shared_root":false,"is_embed_users_root":false,"is_personal":false,"is_personal_descendant":false,"is_shared_root":true,"is_users_root":false,"can":{"index":true,"show":true,"create":true,"see_admin_spaces":true,"update":false,"destroy":false,"move_content":true,"edit_content":true}},{"name":"Users","parent_id":null,"id":2,"content_metadata_id":2,"created_at":"2019-11-16T02:18:31.832+00:00","creator_id":null,"child_count":7,"external_id":null,"is_embed":false,"is_embed_shared_root":false,"is_embed_users_root":false,"is_personal":false,"is_personal_descendant":false,"is_shared_root":false,"is_users_root":true,"can":{"index":true,"show":true,"create":true,"see_admin_spaces":true,"update":false,"destroy":false,"move_content":true,"edit_content":true}},{"name":"pytest","parent_id":8,"id":25,"content_metadata_id":31,"created_at":"2020-06-16T02:31:52.600+00:00","creator_id":3,"child_count":0,"external_id":null,"is_embed":false,"is_embed_shared_root":false,"is_embed_users_root":false,"is_personal":false,"is_personal_descendant":true,"is_shared_root":false,"is_users_root":false,"can":{"index":true,"show":true,"create":true,"see_admin_spaces":true,"update":true,"destroy":true,"move_content":true,"edit_content":true}},{"name":"pytest","parent_id":1,"id":26,"content_metadata_id":36,"created_at":"2020-06-20T15:06:58.344+00:00","creator_id":3,"child_count":0,"external_id":null,"is_embed":false,"is_embed_shared_root":false,"is_embed_users_root":false,"is_personal":false,"is_personal_descendant":false,"is_shared_root":false,"is_users_root":false,"can":{"index":true,"show":true,"create":true,"see_admin_spaces":true,"update":true,"destroy":true,"move_content":true,"edit_content":true}},{"name":"Josh
+        Temple","parent_id":2,"id":8,"content_metadata_id":12,"created_at":"2019-11-19T16:26:33.485+00:00","creator_id":3,"child_count":1,"external_id":null,"is_embed":false,"is_embed_shared_root":false,"is_embed_users_root":false,"is_personal":true,"is_personal_descendant":false,"is_shared_root":false,"is_users_root":false,"can":{"index":true,"show":true,"create":true,"see_admin_spaces":true,"update":false,"destroy":false,"move_content":true,"edit_content":true}},{"name":"LookML
+        Dashboards","parent_id":null,"id":"lookml","content_metadata_id":null,"created_at":null,"creator_id":null,"child_count":0,"external_id":null,"is_embed":false,"is_embed_shared_root":false,"is_embed_users_root":false,"is_personal":false,"is_personal_descendant":false,"is_shared_root":false,"is_users_root":false,"can":{"index":true,"show":true,"create":true,"see_admin_spaces":true,"update":false,"destroy":false,"move_content":false,"edit_content":false}}]'
+    headers:
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '2836'
+      Content-Type:
+      - application/json
+      Date:
+      - Sat, 20 Jun 2020 16:31:38 GMT
+      Server:
+      - nginx
+      Vary:
+      - Accept-Encoding
+      - Accept-Encoding
+      X-Content-Type-Options:
+      - nosniff
+    status:
+      code: 200
+      message: OK
+- request:
+    body: null
+    headers:
+      Cookie:
+      - looker.browser=5144
+    method: GET
+    uri: https://spectacles.looker.com:19999/api/3.1/folders
+  response:
+    body:
+      string: '[{"name":"Shared","parent_id":null,"id":1,"content_metadata_id":1,"created_at":"2019-11-16T02:18:31.829+00:00","creator_id":null,"child_count":1,"external_id":null,"is_embed":false,"is_embed_shared_root":false,"is_embed_users_root":false,"is_personal":false,"is_personal_descendant":false,"is_shared_root":true,"is_users_root":false,"can":{"index":true,"show":true,"create":true,"see_admin_spaces":true,"update":false,"destroy":false,"move_content":true,"edit_content":true}},{"name":"Users","parent_id":null,"id":2,"content_metadata_id":2,"created_at":"2019-11-16T02:18:31.832+00:00","creator_id":null,"child_count":7,"external_id":null,"is_embed":false,"is_embed_shared_root":false,"is_embed_users_root":false,"is_personal":false,"is_personal_descendant":false,"is_shared_root":false,"is_users_root":true,"can":{"index":true,"show":true,"create":true,"see_admin_spaces":true,"update":false,"destroy":false,"move_content":true,"edit_content":true}},{"name":"pytest","parent_id":8,"id":25,"content_metadata_id":31,"created_at":"2020-06-16T02:31:52.600+00:00","creator_id":3,"child_count":0,"external_id":null,"is_embed":false,"is_embed_shared_root":false,"is_embed_users_root":false,"is_personal":false,"is_personal_descendant":true,"is_shared_root":false,"is_users_root":false,"can":{"index":true,"show":true,"create":true,"see_admin_spaces":true,"update":true,"destroy":true,"move_content":true,"edit_content":true}},{"name":"pytest","parent_id":1,"id":26,"content_metadata_id":36,"created_at":"2020-06-20T15:06:58.344+00:00","creator_id":3,"child_count":0,"external_id":null,"is_embed":false,"is_embed_shared_root":false,"is_embed_users_root":false,"is_personal":false,"is_personal_descendant":false,"is_shared_root":false,"is_users_root":false,"can":{"index":true,"show":true,"create":true,"see_admin_spaces":true,"update":true,"destroy":true,"move_content":true,"edit_content":true}},{"name":"Josh
+        Temple","parent_id":2,"id":8,"content_metadata_id":12,"created_at":"2019-11-19T16:26:33.485+00:00","creator_id":3,"child_count":1,"external_id":null,"is_embed":false,"is_embed_shared_root":false,"is_embed_users_root":false,"is_personal":true,"is_personal_descendant":false,"is_shared_root":false,"is_users_root":false,"can":{"index":true,"show":true,"create":true,"see_admin_spaces":true,"update":false,"destroy":false,"move_content":true,"edit_content":true}},{"name":"LookML
+        Dashboards","parent_id":null,"id":"lookml","content_metadata_id":null,"created_at":null,"creator_id":null,"child_count":0,"external_id":null,"is_embed":false,"is_embed_shared_root":false,"is_embed_users_root":false,"is_personal":false,"is_personal_descendant":false,"is_shared_root":false,"is_users_root":false,"can":{"index":true,"show":true,"create":true,"see_admin_spaces":true,"update":false,"destroy":false,"move_content":false,"edit_content":false}}]'
+    headers:
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '2836'
+      Content-Type:
+      - application/json
+      Date:
+      - Sat, 20 Jun 2020 16:31:38 GMT
+      Server:
+      - nginx
+      Vary:
+      - Accept-Encoding
+      - Accept-Encoding
+      X-Content-Type-Options:
+      - nosniff
+    status:
+      code: 200
+      message: OK
+version: 1
diff --git a/tests/cassettes/test_content_validator/fixture_validator_pass.yaml b/tests/cassettes/test_content_validator/fixture_validator_pass.yaml
new file mode 100644
index 0000000..c7f2174
--- /dev/null
+++ b/tests/cassettes/test_content_validator/fixture_validator_pass.yaml
@@ -0,0 +1,240 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Cookie:
+      - looker.browser=5144
+    method: GET
+    uri: https://spectacles.looker.com:19999/api/3.1/lookml_models
+  response:
+    body:
+      string: '[{"explores":[{"description":null,"label":"Cohort Data Tool","hidden":true,"group_label":"1)
+        eCommerce with Event Data","name":"cohorts","can":{}},{"description":null,"label":"Web
+        Analytics Data Tool","hidden":true,"group_label":"1) eCommerce with Event
+        Data","name":"data_tool","can":{}},{"description":null,"label":"(1) Orders,
+        Items and Users","hidden":false,"group_label":"1) eCommerce with Event Data","name":"order_items","can":{}},{"description":null,"label":"(2)
+        Web Event Data","hidden":false,"group_label":"1) eCommerce with Event Data","name":"events","can":{}},{"description":null,"label":"(3)
+        Web Session Data","hidden":false,"group_label":"1) eCommerce with Event Data","name":"sessions","can":{}},{"description":null,"label":"(4)
+        Affinity Analysis","hidden":false,"group_label":"1) eCommerce with Event Data","name":"affinity","can":{}},{"description":null,"label":"(5)
+        Share of Wallet Analysis","hidden":false,"group_label":"1) eCommerce with
+        Event Data","name":"orders_with_share_of_wallet_application","can":{}},{"description":null,"label":"(6)
+        Customer Journey Mapping","hidden":false,"group_label":"1) eCommerce with
+        Event Data","name":"journey_mapping","can":{}},{"description":null,"label":"(7)
+        Stock Analysis","hidden":false,"group_label":"1) eCommerce with Event Data","name":"inventory_items","can":{}},{"description":null,"label":"(8)
+        Historical Stock Snapshot Analysis","hidden":false,"group_label":"1) eCommerce
+        with Event Data","name":"inventory_snapshot","can":{}},{"description":null,"label":"Order
+        Items (Kittens)","hidden":true,"group_label":"1) eCommerce with Event Data","name":"kitten_order_items","can":{}}],"has_content":true,"label":"1)
+        eCommerce with Event Data","name":"thelook","project_name":"welcome_to_looker","unlimited_db_connections":false,"allowed_db_connection_names":["snowlooker"],"can":{"index":true,"show":true,"create":true,"update":true,"destroy":true}},{"explores":[{"description":null,"label":"Orders","hidden":false,"group_label":"Jaffle
+        Shop","name":"fct_orders","can":{}}],"has_content":true,"label":"Jaffle Shop","name":"jaffle_shop","project_name":"jaffle_shop","unlimited_db_connections":false,"allowed_db_connection_names":["bigquery"],"can":{"index":true,"show":true,"create":true,"update":true,"destroy":true}},{"explores":[],"has_content":false,"label":"Admin","name":"admin","project_name":"jaffle_shop","unlimited_db_connections":false,"allowed_db_connection_names":["bigquery"],"can":{"index":true,"show":true,"create":true,"update":true,"destroy":true}},{"explores":[{"description":null,"label":"Users","hidden":false,"group_label":"Eye
+        Exam","name":"users","can":{}},{"description":null,"label":"Users Fail","hidden":false,"group_label":"Eye
+        Exam","name":"users__fail","can":{}},{"description":null,"label":"Users Fail
+        and Warn","hidden":false,"group_label":"Eye Exam","name":"users__fail_and_warn","can":{}},{"description":null,"label":"Users
+        Warn","hidden":false,"group_label":"Eye Exam","name":"users__warn","can":{}}],"has_content":true,"label":"Eye
+        Exam","name":"eye_exam","project_name":"eye_exam","unlimited_db_connections":false,"allowed_db_connection_names":["snowlooker"],"can":{"index":true,"show":true,"create":true,"update":true,"destroy":true}},{"explores":[{"description":null,"label":"Content
+        Usage","hidden":false,"group_label":"System Activity","name":"content_usage","can":{}},{"description":null,"label":"Dashboard","hidden":false,"group_label":"System
+        Activity","name":"dashboard","can":{}},{"description":null,"label":"Dashboard
+        Performance","hidden":false,"group_label":"System Activity","name":"dashboard_performance","can":{}},{"description":null,"label":"DB
+        Connection","hidden":false,"group_label":"System Activity","name":"db_connection","can":{}},{"description":null,"label":"Event","hidden":false,"group_label":"System
+        Activity","name":"event","can":{}},{"description":null,"label":"Event Attribute","hidden":false,"group_label":"System
+        Activity","name":"event_attribute","can":{}},{"description":null,"label":"Field
+        Usage","hidden":false,"group_label":"System Activity","name":"field_usage","can":{}},{"description":null,"label":"Group","hidden":false,"group_label":"System
+        Activity","name":"group","can":{}},{"description":null,"label":"History","hidden":false,"group_label":"System
+        Activity","name":"history","can":{}},{"description":null,"label":"Look","hidden":false,"group_label":"System
+        Activity","name":"look","can":{}},{"description":null,"label":"Merge Query","hidden":false,"group_label":"System
+        Activity","name":"merge_query","can":{}},{"description":null,"label":"PDT
+        Event Log","hidden":false,"group_label":"System Activity","name":"pdt_event_log","can":{}},{"description":null,"label":"PDT
+        Builds","hidden":false,"group_label":"System Activity","name":"pdt_builds","can":{}},{"description":null,"label":"Result
+        Maker Lookup","hidden":false,"group_label":"System Activity","name":"result_maker_lookup","can":{}},{"description":null,"label":"Scheduled
+        Plan","hidden":false,"group_label":"System Activity","name":"scheduled_plan","can":{}},{"description":null,"label":"Space","hidden":false,"group_label":"System
+        Activity","name":"space","can":{}},{"description":null,"label":"SQL Query","hidden":false,"group_label":"System
+        Activity","name":"sql_query","can":{}},{"description":null,"label":"User","hidden":false,"group_label":"System
+        Activity","name":"user","can":{}},{"description":null,"label":"Role","hidden":false,"group_label":"System
+        Activity","name":"role","can":{}}],"has_content":true,"label":"System Activity","name":"system__activity","project_name":"system__activity","unlimited_db_connections":false,"allowed_db_connection_names":[],"can":{"index":true,"show":true,"create":true,"update":true,"destroy":true}}]'
+    headers:
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '5740'
+      Content-Type:
+      - application/json
+      Date:
+      - Sat, 20 Jun 2020 16:31:37 GMT
+      Server:
+      - nginx
+      Vary:
+      - Accept-Encoding
+      - Accept-Encoding
+      X-Content-Type-Options:
+      - nosniff
+    status:
+      code: 200
+      message: OK
+- request:
+    body: null
+    headers:
+      Cookie:
+      - looker.browser=5144
+    method: GET
+    uri: https://spectacles.looker.com:19999/api/3.1/content_validation
+  response:
+    body:
+      string: '{"content_with_errors":[{"look":null,"dashboard":{"description":"","id":1,"title":"Business
+        Pulse","space":{"name":"Shared","id":1},"folder":{"name":"Shared","id":1}},"dashboard_element":{"body_text":null,"dashboard_id":1,"id":2,"look_id":null,"note_display":"below","note_state":"collapsed","note_text":"","note_text_as_html":"","query_id":null,"subtitle_text":null,"title":"Average
+        Order Sale Price","title_hidden":false,"title_text":null,"type":"vis"},"dashboard_filter":null,"scheduled_plan":null,"errors":[{"message":"Unknown
+        field \"orders.average_profit\".","field_name":"orders.average_profit","model_name":"thelook","explore_name":"order_items","removable":true}],"id":0},{"look":null,"dashboard":{"description":"","id":1,"title":"Business
+        Pulse","space":{"name":"Shared","id":1},"folder":{"name":"Shared","id":1}},"dashboard_element":{"body_text":null,"dashboard_id":1,"id":12,"look_id":null,"note_display":"hover","note_state":"collapsed","note_text":"What
+        percent of orders are followed by a repeat purchase by the same user within
+        30 days?","note_text_as_html":"What percent of orders are followed by a repeat
+        purchase by the same user within 30 days?","query_id":null,"subtitle_text":null,"title":"30
+        Day Repeat Purchase Rate","title_hidden":false,"title_text":null,"type":"vis"},"dashboard_filter":null,"scheduled_plan":null,"errors":[{"message":"Unknown
+        field \"repeat_purchase_facts.30_day_repeat_purchase_rate\".","field_name":"repeat_purchase_facts.30_day_repeat_purchase_rate","model_name":"thelook","explore_name":"order_items","removable":true}],"id":1},{"look":null,"dashboard":{"description":null,"id":2,"title":"Brand
+        Analytics, Web \u0026 Transactional","space":{"name":"Shared","id":1},"folder":{"name":"Shared","id":1}},"dashboard_element":{"body_text":null,"dashboard_id":2,"id":24,"look_id":null,"note_display":null,"note_state":null,"note_text":null,"note_text_as_html":null,"query_id":null,"subtitle_text":null,"title":"Brand
+        Share of Wallet over Customer Lifetime","title_hidden":false,"title_text":null,"type":"vis"},"dashboard_filter":null,"scheduled_plan":null,"errors":[{"message":"Unknown
+        field \"order_items.share_of_wallet_brand_within_company\".","field_name":"order_items.share_of_wallet_brand_within_company","model_name":"thelook","explore_name":"orders_with_share_of_wallet_application","removable":true},{"message":"Unknown
+        field \"order_items.item_comparison\".","field_name":"order_items.item_comparison","model_name":"thelook","explore_name":"orders_with_share_of_wallet_application","removable":true}],"id":2},{"look":{"id":7,"title":"Top
+        10 Users by State [fail]","space":{"name":"pytest","id":25},"folder":{"name":"pytest","id":25}},"dashboard":null,"dashboard_element":null,"dashboard_filter":null,"scheduled_plan":null,"errors":[{"message":"Unknown
+        field \"users__fail.state\".","field_name":"users__fail.state","model_name":"eye_exam","explore_name":"users__fail","removable":true}],"id":3},{"look":{"id":8,"title":"Top
+        10 Users by State [fail]","space":{"name":"Josh Temple","id":8},"folder":{"name":"Josh
+        Temple","id":8}},"dashboard":null,"dashboard_element":null,"dashboard_filter":null,"scheduled_plan":null,"errors":[{"message":"Unknown
+        field \"users__fail.state\".","field_name":"users__fail.state","model_name":"eye_exam","explore_name":"users__fail","removable":true}],"id":4},{"look":{"id":9,"title":"Top
+        10 Users by State [fail]","space":{"name":"pytest","id":26},"folder":{"name":"pytest","id":26}},"dashboard":null,"dashboard_element":null,"dashboard_filter":null,"scheduled_plan":null,"errors":[{"message":"Unknown
+        field \"users__fail.state\".","field_name":"users__fail.state","model_name":"eye_exam","explore_name":"users__fail","removable":true}],"id":5}],"computation_time":0.13295200000000001,"total_looks_validated":4,"total_dashboard_elements_validated":34,"total_dashboard_filters_validated":5,"total_scheduled_plans_validated":0,"total_explores_validated":8}'
+    headers:
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '3935'
+      Content-Type:
+      - application/json
+      Date:
+      - Sat, 20 Jun 2020 16:31:37 GMT
+      Server:
+      - nginx
+      Vary:
+      - Accept-Encoding
+      - Accept-Encoding
+      X-Content-Type-Options:
+      - nosniff
+    status:
+      code: 200
+      message: OK
+- request:
+    body: null
+    headers:
+      Cookie:
+      - looker.browser=5144
+    method: GET
+    uri: https://spectacles.looker.com:19999/api/3.1/lookml_models
+  response:
+    body:
+      string: '[{"explores":[{"description":null,"label":"Cohort Data Tool","hidden":true,"group_label":"1)
+        eCommerce with Event Data","name":"cohorts","can":{}},{"description":null,"label":"Web
+        Analytics Data Tool","hidden":true,"group_label":"1) eCommerce with Event
+        Data","name":"data_tool","can":{}},{"description":null,"label":"(1) Orders,
+        Items and Users","hidden":false,"group_label":"1) eCommerce with Event Data","name":"order_items","can":{}},{"description":null,"label":"(2)
+        Web Event Data","hidden":false,"group_label":"1) eCommerce with Event Data","name":"events","can":{}},{"description":null,"label":"(3)
+        Web Session Data","hidden":false,"group_label":"1) eCommerce with Event Data","name":"sessions","can":{}},{"description":null,"label":"(4)
+        Affinity Analysis","hidden":false,"group_label":"1) eCommerce with Event Data","name":"affinity","can":{}},{"description":null,"label":"(5)
+        Share of Wallet Analysis","hidden":false,"group_label":"1) eCommerce with
+        Event Data","name":"orders_with_share_of_wallet_application","can":{}},{"description":null,"label":"(6)
+        Customer Journey Mapping","hidden":false,"group_label":"1) eCommerce with
+        Event Data","name":"journey_mapping","can":{}},{"description":null,"label":"(7)
+        Stock Analysis","hidden":false,"group_label":"1) eCommerce with Event Data","name":"inventory_items","can":{}},{"description":null,"label":"(8)
+        Historical Stock Snapshot Analysis","hidden":false,"group_label":"1) eCommerce
+        with Event Data","name":"inventory_snapshot","can":{}},{"description":null,"label":"Order
+        Items (Kittens)","hidden":true,"group_label":"1) eCommerce with Event Data","name":"kitten_order_items","can":{}}],"has_content":true,"label":"1)
+        eCommerce with Event Data","name":"thelook","project_name":"welcome_to_looker","unlimited_db_connections":false,"allowed_db_connection_names":["snowlooker"],"can":{"index":true,"show":true,"create":true,"update":true,"destroy":true}},{"explores":[{"description":null,"label":"Orders","hidden":false,"group_label":"Jaffle
+        Shop","name":"fct_orders","can":{}}],"has_content":true,"label":"Jaffle Shop","name":"jaffle_shop","project_name":"jaffle_shop","unlimited_db_connections":false,"allowed_db_connection_names":["bigquery"],"can":{"index":true,"show":true,"create":true,"update":true,"destroy":true}},{"explores":[],"has_content":false,"label":"Admin","name":"admin","project_name":"jaffle_shop","unlimited_db_connections":false,"allowed_db_connection_names":["bigquery"],"can":{"index":true,"show":true,"create":true,"update":true,"destroy":true}},{"explores":[{"description":null,"label":"Users","hidden":false,"group_label":"Eye
+        Exam","name":"users","can":{}},{"description":null,"label":"Users Fail","hidden":false,"group_label":"Eye
+        Exam","name":"users__fail","can":{}},{"description":null,"label":"Users Fail
+        and Warn","hidden":false,"group_label":"Eye Exam","name":"users__fail_and_warn","can":{}},{"description":null,"label":"Users
+        Warn","hidden":false,"group_label":"Eye Exam","name":"users__warn","can":{}}],"has_content":true,"label":"Eye
+        Exam","name":"eye_exam","project_name":"eye_exam","unlimited_db_connections":false,"allowed_db_connection_names":["snowlooker"],"can":{"index":true,"show":true,"create":true,"update":true,"destroy":true}},{"explores":[{"description":null,"label":"Content
+        Usage","hidden":false,"group_label":"System Activity","name":"content_usage","can":{}},{"description":null,"label":"Dashboard","hidden":false,"group_label":"System
+        Activity","name":"dashboard","can":{}},{"description":null,"label":"Dashboard
+        Performance","hidden":false,"group_label":"System Activity","name":"dashboard_performance","can":{}},{"description":null,"label":"DB
+        Connection","hidden":false,"group_label":"System Activity","name":"db_connection","can":{}},{"description":null,"label":"Event","hidden":false,"group_label":"System
+        Activity","name":"event","can":{}},{"description":null,"label":"Event Attribute","hidden":false,"group_label":"System
+        Activity","name":"event_attribute","can":{}},{"description":null,"label":"Field
+        Usage","hidden":false,"group_label":"System Activity","name":"field_usage","can":{}},{"description":null,"label":"Group","hidden":false,"group_label":"System
+        Activity","name":"group","can":{}},{"description":null,"label":"History","hidden":false,"group_label":"System
+        Activity","name":"history","can":{}},{"description":null,"label":"Look","hidden":false,"group_label":"System
+        Activity","name":"look","can":{}},{"description":null,"label":"Merge Query","hidden":false,"group_label":"System
+        Activity","name":"merge_query","can":{}},{"description":null,"label":"PDT
+        Event Log","hidden":false,"group_label":"System Activity","name":"pdt_event_log","can":{}},{"description":null,"label":"PDT
+        Builds","hidden":false,"group_label":"System Activity","name":"pdt_builds","can":{}},{"description":null,"label":"Result
+        Maker Lookup","hidden":false,"group_label":"System Activity","name":"result_maker_lookup","can":{}},{"description":null,"label":"Scheduled
+        Plan","hidden":false,"group_label":"System Activity","name":"scheduled_plan","can":{}},{"description":null,"label":"Space","hidden":false,"group_label":"System
+        Activity","name":"space","can":{}},{"description":null,"label":"SQL Query","hidden":false,"group_label":"System
+        Activity","name":"sql_query","can":{}},{"description":null,"label":"User","hidden":false,"group_label":"System
+        Activity","name":"user","can":{}},{"description":null,"label":"Role","hidden":false,"group_label":"System
+        Activity","name":"role","can":{}}],"has_content":true,"label":"System Activity","name":"system__activity","project_name":"system__activity","unlimited_db_connections":false,"allowed_db_connection_names":[],"can":{"index":true,"show":true,"create":true,"update":true,"destroy":true}}]'
+    headers:
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '5740'
+      Content-Type:
+      - application/json
+      Date:
+      - Sat, 20 Jun 2020 16:31:38 GMT
+      Server:
+      - nginx
+      Vary:
+      - Accept-Encoding
+      - Accept-Encoding
+      X-Content-Type-Options:
+      - nosniff
+    status:
+      code: 200
+      message: OK
+- request:
+    body: null
+    headers:
+      Cookie:
+      - looker.browser=5144
+    method: GET
+    uri: https://spectacles.looker.com:19999/api/3.1/content_validation
+  response:
+    body:
+      string: '{"content_with_errors":[{"look":null,"dashboard":{"description":"","id":1,"title":"Business
+        Pulse","space":{"name":"Shared","id":1},"folder":{"name":"Shared","id":1}},"dashboard_element":{"body_text":null,"dashboard_id":1,"id":2,"look_id":null,"note_display":"below","note_state":"collapsed","note_text":"","note_text_as_html":"","query_id":null,"subtitle_text":null,"title":"Average
+        Order Sale Price","title_hidden":false,"title_text":null,"type":"vis"},"dashboard_filter":null,"scheduled_plan":null,"errors":[{"message":"Unknown
+        field \"orders.average_profit\".","field_name":"orders.average_profit","model_name":"thelook","explore_name":"order_items","removable":true}],"id":0},{"look":null,"dashboard":{"description":"","id":1,"title":"Business
+        Pulse","space":{"name":"Shared","id":1},"folder":{"name":"Shared","id":1}},"dashboard_element":{"body_text":null,"dashboard_id":1,"id":12,"look_id":null,"note_display":"hover","note_state":"collapsed","note_text":"What
+        percent of orders are followed by a repeat purchase by the same user within
+        30 days?","note_text_as_html":"What percent of orders are followed by a repeat
+        purchase by the same user within 30 days?","query_id":null,"subtitle_text":null,"title":"30
+        Day Repeat Purchase Rate","title_hidden":false,"title_text":null,"type":"vis"},"dashboard_filter":null,"scheduled_plan":null,"errors":[{"message":"Unknown
+        field \"repeat_purchase_facts.30_day_repeat_purchase_rate\".","field_name":"repeat_purchase_facts.30_day_repeat_purchase_rate","model_name":"thelook","explore_name":"order_items","removable":true}],"id":1},{"look":null,"dashboard":{"description":null,"id":2,"title":"Brand
+        Analytics, Web \u0026 Transactional","space":{"name":"Shared","id":1},"folder":{"name":"Shared","id":1}},"dashboard_element":{"body_text":null,"dashboard_id":2,"id":24,"look_id":null,"note_display":null,"note_state":null,"note_text":null,"note_text_as_html":null,"query_id":null,"subtitle_text":null,"title":"Brand
+        Share of Wallet over Customer Lifetime","title_hidden":false,"title_text":null,"type":"vis"},"dashboard_filter":null,"scheduled_plan":null,"errors":[{"message":"Unknown
+        field \"order_items.share_of_wallet_brand_within_company\".","field_name":"order_items.share_of_wallet_brand_within_company","model_name":"thelook","explore_name":"orders_with_share_of_wallet_application","removable":true},{"message":"Unknown
+        field \"order_items.item_comparison\".","field_name":"order_items.item_comparison","model_name":"thelook","explore_name":"orders_with_share_of_wallet_application","removable":true}],"id":2},{"look":{"id":7,"title":"Top
+        10 Users by State [fail]","space":{"name":"pytest","id":25},"folder":{"name":"pytest","id":25}},"dashboard":null,"dashboard_element":null,"dashboard_filter":null,"scheduled_plan":null,"errors":[{"message":"Unknown
+        field \"users__fail.state\".","field_name":"users__fail.state","model_name":"eye_exam","explore_name":"users__fail","removable":true}],"id":3},{"look":{"id":8,"title":"Top
+        10 Users by State [fail]","space":{"name":"Josh Temple","id":8},"folder":{"name":"Josh
+        Temple","id":8}},"dashboard":null,"dashboard_element":null,"dashboard_filter":null,"scheduled_plan":null,"errors":[{"message":"Unknown
+        field \"users__fail.state\".","field_name":"users__fail.state","model_name":"eye_exam","explore_name":"users__fail","removable":true}],"id":4},{"look":{"id":9,"title":"Top
+        10 Users by State [fail]","space":{"name":"pytest","id":26},"folder":{"name":"pytest","id":26}},"dashboard":null,"dashboard_element":null,"dashboard_filter":null,"scheduled_plan":null,"errors":[{"message":"Unknown
+        field \"users__fail.state\".","field_name":"users__fail.state","model_name":"eye_exam","explore_name":"users__fail","removable":true}],"id":5}],"computation_time":0.13093000000000002,"total_looks_validated":4,"total_dashboard_elements_validated":34,"total_dashboard_filters_validated":5,"total_scheduled_plans_validated":0,"total_explores_validated":8}'
+    headers:
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '3935'
+      Content-Type:
+      - application/json
+      Date:
+      - Sat, 20 Jun 2020 16:31:38 GMT
+      Server:
+      - nginx
+      Vary:
+      - Accept-Encoding
+      - Accept-Encoding
+      X-Content-Type-Options:
+      - nosniff
+    status:
+      code: 200
+      message: OK
+version: 1
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 528b9df..401b3ae 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -283,6 +283,33 @@ def test_main_with_sql_validator(mock_tracking, mock_run_sql, env):
     )
 
 
+@patch("sys.argv", new=["spectacles", "content"])
+@patch("spectacles.cli.run_content")
+@patch("spectacles.cli.tracking")
+def test_main_with_content_validator(mock_tracking, mock_run_content, env):
+    main()
+    mock_tracking.track_invocation_start.assert_called_once_with(
+        "BASE_URL_ENV_VAR", "content", project="PROJECT_ENV_VAR"
+    )
+    mock_tracking.track_invocation_end.assert_called_once()
+    mock_run_content.assert_called_once_with(
+        "PROJECT_ENV_VAR",  # project
+        "BRANCH_ENV_VAR",  # branch
+        ["*/*"],  # explores
+        [],  # exclude
+        "BASE_URL_ENV_VAR",  # base_url
+        "CLIENT_ID_ENV_VAR",  # client_id
+        "CLIENT_SECRET_ENV_VAR",  # client_secret
+        8080,  # port
+        3.1,  # api_version
+        False,  # remote_reset
+        False,  # import_projects
+        None,  # commit_ref
+        False,  # incremental
+        False,  # exclude_personal
+    )
+
+
 @patch("sys.argv", new=["spectacles", "assert"])
 @patch("spectacles.cli.run_assert")
 @patch("spectacles.cli.tracking")
diff --git a/tests/test_client.py b/tests/test_client.py
index 1b42300..ce87eb2 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -81,6 +81,8 @@ def client_kwargs():
         get_active_branch_name={"project": "project_name"},
         get_manifest={"project": "project_name"},
         get_all_branches={"project": "project_name"},
+        content_validation={},
+        all_folders={"project": "project_name"},
     )
 
 
diff --git a/tests/test_content_validator.py b/tests/test_content_validator.py
new file mode 100644
index 0000000..64cc85e
--- /dev/null
+++ b/tests/test_content_validator.py
@@ -0,0 +1,85 @@
+from typing import Iterable, Tuple, Dict
+import pytest
+import jsonschema
+import vcr
+from spectacles.validators import ContentValidator
+
+
+@pytest.fixture(scope="class")
+def validator(looker_client, record_mode) -> Iterable[ContentValidator]:
+    with vcr.use_cassette(
+        "tests/cassettes/test_content_validator/fixture_validator_init.yaml",
+        match_on=["uri", "method", "raw_body"],
+        filter_headers=["Authorization"],
+        record_mode=record_mode,
+    ):
+        validator = ContentValidator(
+            looker_client, project="eye_exam", exclude_personal=True
+        )
+        yield validator
+
+
+def test_get_content_type_with_bad_keys_should_raise_key_error(validator):
+    content = {"lookml_dashboard": "Something goes here."}
+    with pytest.raises(KeyError):
+        validator._get_content_type(content)
+
+
+class TestValidatePass:
+    """Test the eye_exam Looker project on master for an explore without errors."""
+
+    @pytest.fixture(scope="class")
+    def validator_pass(
+        self, record_mode, validator
+    ) -> Iterable[Tuple[ContentValidator, Dict]]:
+        with vcr.use_cassette(
+            "tests/cassettes/test_content_validator/fixture_validator_pass.yaml",
+            match_on=["uri", "method", "raw_body"],
+            filter_headers=["Authorization"],
+            record_mode=record_mode,
+        ):
+            validator.build_project(selectors=["eye_exam/users"])
+            results = validator.validate()
+            yield validator, results
+
+    def test_should_set_errored_and_queried(self, validator_pass):
+        validator = validator_pass[0]
+        assert validator.project.errored is False
+        assert validator.project.queried is True
+
+    def test_results_should_conform_to_schema(self, schema, validator_pass):
+        results = validator_pass[1]
+        jsonschema.validate(results, schema)
+
+
+class TestValidateFail:
+    """Test the eye_exam Looker project on master for an explore without errors."""
+
+    @pytest.fixture(scope="class")
+    def validator_fail(
+        self, record_mode, validator
+    ) -> Iterable[Tuple[ContentValidator, Dict]]:
+        with vcr.use_cassette(
+            "tests/cassettes/test_content_validator/fixture_validator_fail.yaml",
+            match_on=["uri", "method", "raw_body"],
+            filter_headers=["Authorization"],
+            record_mode=record_mode,
+        ):
+            validator.build_project(selectors=["eye_exam/users__fail"])
+            results = validator.validate()
+            yield validator, results
+
+    def test_should_set_errored_and_queried(self, validator_fail):
+        validator = validator_fail[0]
+        assert validator.project.errored is True
+        assert validator.project.queried is True
+
+    def test_results_should_conform_to_schema(self, schema, validator_fail):
+        results = validator_fail[1]
+        jsonschema.validate(results, schema)
+
+    def test_personal_folder_content_should_not_be_present(self, validator_fail):
+        results = validator_fail[1]
+        titles = [error["metadata"]["title"] for error in results["errors"]]
+        # All failing content in personal spaces has been tagged with "[personal]"
+        assert "personal" not in titles
diff --git a/tests/test_logger.py b/tests/test_logger.py
index ad81455..f514c45 100644
--- a/tests/test_logger.py
+++ b/tests/test_logger.py
@@ -10,7 +10,7 @@ def test_logging_failing_explore_sql(tmpdir, sql_error):
     log_sql_error(
         sql_error.model,
         sql_error.explore,
-        sql_error.test,
+        sql_error.metadata["sql"],
         tmpdir,
         sql_error.metadata["dimension"],
     )
@@ -29,7 +29,7 @@ def test_logging_failing_dimension_sql(tmpdir, sql_error):
     log_sql_error(
         sql_error.model,
         sql_error.explore,
-        sql_error.test,
+        sql_error.metadata["sql"],
         tmpdir,
         sql_error.metadata["dimension"],
     )
diff --git a/tests/test_lookml.py b/tests/test_lookml.py
index 5c41859..8c4785e 100644
--- a/tests/test_lookml.py
+++ b/tests/test_lookml.py
@@ -69,6 +69,19 @@ def test_comparison_to_mismatched_type_object_should_fail(request, obj_name):
     assert lookml_obj != SomethingElse()
 
 
+def test_assign_to_errored_should_raise_attribute_error(project):
+    project.models = []
+    with pytest.raises(AttributeError):
+        project.errored = True
+
+
+@pytest.mark.parametrize("obj_name", ("model", "project"))
+def test_non_bool_errored_should_raise_value_error(request, obj_name):
+    lookml_obj = request.getfixturevalue(obj_name)
+    with pytest.raises(TypeError):
+        lookml_obj.errored = 1
+
+
 def test_dimensions_with_different_sql_can_be_equal(dimension):
     a = dimension
     b = deepcopy(a)
@@ -78,7 +91,7 @@ def test_dimensions_with_different_sql_can_be_equal(dimension):
 
 def test_dimension_should_not_be_errored_if_not_queried(dimension, sql_error):
     assert dimension.errored is None
-    dimension.error = sql_error
+    dimension.errors = [sql_error]
     assert dimension.errored is None
     dimension.queried = True
     assert dimension.errored is True
@@ -125,7 +138,7 @@ def test_comparison_to_mismatched_type_object_fails(dimension, explore, model, p
 def test_explore_number_of_errors_batch_with_errors(dimension, explore, sql_error):
     explore.dimensions = [dimension]
     explore.queried = True
-    explore.error = sql_error
+    explore.errors = [sql_error]
     assert explore.number_of_errors == 1
 
 
@@ -136,7 +149,7 @@ def test_explore_number_of_errors_batch_with_no_errors(dimension, explore, sql_e
 
 
 def test_explore_number_of_errors_single_with_errors(dimension, explore, sql_error):
-    dimension.error = sql_error
+    dimension.errors = [sql_error]
     dimension.queried = True
     explore.dimensions = [dimension, dimension]
     assert explore.number_of_errors == 2
@@ -151,7 +164,7 @@ def test_explore_number_of_errors_single_with_no_errors(dimension, explore, sql_
 def test_model_number_of_errors_batch_with_errors(dimension, explore, model, sql_error):
     explore.dimensions = [dimension]
     explore.queried = True
-    explore.error = sql_error
+    explore.errors = [sql_error]
     model.explores = [explore, explore]
     assert model.number_of_errors == 2
 
@@ -168,7 +181,7 @@ def test_model_number_of_errors_batch_with_no_errors(
 def test_model_number_of_errors_single_with_errors(
     dimension, explore, model, sql_error
 ):
-    dimension.error = sql_error
+    dimension.errors = [sql_error]
     explore.dimensions = [dimension, dimension]
     explore.queried = True
     model.explores = [explore, explore]
@@ -189,7 +202,7 @@ def test_project_number_of_errors_batch_with_errors(
 ):
     explore.dimensions = [dimension]
     explore.queried = True
-    explore.error = sql_error
+    explore.errors = [sql_error]
     model.explores = [explore, explore]
     project.models = [model]
     assert project.number_of_errors == 2
@@ -208,7 +221,7 @@ def test_project_number_of_errors_batch_with_no_errors(
 def test_project_number_of_errors_single_with_errors(
     dimension, explore, model, project, sql_error
 ):
-    dimension.error = sql_error
+    dimension.errors = [sql_error]
     explore.dimensions = [dimension, dimension]
     explore.queried = True
     model.explores = [explore, explore]
diff --git a/tests/test_printer.py b/tests/test_printer.py
index bcf3052..a79d718 100644
--- a/tests/test_printer.py
+++ b/tests/test_printer.py
@@ -1,3 +1,4 @@
+from unittest.mock import patch
 from spectacles import printer
 from spectacles.logger import delete_color_codes
 
@@ -37,3 +38,80 @@ def test_mark_line_even_number_of_lines():
     result = printer.mark_line(lines=text, line_number=2)
     result = [delete_color_codes(line) for line in result]
     assert result == expected_result
+
+
+@patch("spectacles.printer.log_sql_error", return_value="path_to_sql_file")
+def test_sql_error_prints_with_relevant_info(mock_log, sql_error, caplog):
+    model = "model_a"
+    explore = "explore_a"
+    dimension = "view_a.dimension_a"
+    message = "A super important error occurred."
+
+    printer.print_sql_error(
+        model=model,
+        explore=explore,
+        message=message,
+        sql="SELECT * FROM test",
+        log_dir="logs",
+    )
+    assert "LookML:" not in caplog.text
+    assert model in caplog.text
+    assert explore in caplog.text
+    assert message in caplog.text
+    assert dimension not in caplog.text
+
+    printer.print_sql_error(
+        model=model,
+        explore=explore,
+        message=message,
+        sql="SELECT * FROM test",
+        log_dir=None,
+        lookml_url="https://spectacles.looker.com",
+    )
+    assert "LookML:" in caplog.text
+
+
+def test_content_error_prints_with_relevant_info(sql_error, caplog):
+    model = "model_a"
+    explore = "explore_a"
+    content_type = "dashboard"
+    space = "Shared"
+    message = "A super important error occurred."
+    title = "My Dashboard"
+
+    printer.print_content_error(
+        model=model,
+        explore=explore,
+        message=message,
+        content_type=content_type,
+        space=space,
+        title=title,
+        url="https://spectacles.looker.com",
+    )
+    assert model in caplog.text
+    assert explore in caplog.text
+    assert content_type.title() in caplog.text
+    assert message in caplog.text
+    assert space in caplog.text
+    assert title in caplog.text
+
+
+def test_data_test_error_prints_with_relevant_info(sql_error, caplog):
+    model = "model_a"
+    explore = "explore_a"
+    test_name = "assert_metric_is_positive"
+    message = "A super important error occurred."
+    lookml_url = "https://spectacles.looker.com"
+
+    printer.print_data_test_error(
+        model=model,
+        explore=explore,
+        test_name=test_name,
+        message=message,
+        lookml_url=lookml_url,
+    )
+    assert model in caplog.text
+    assert explore in caplog.text
+    assert test_name in caplog.text
+    assert message in caplog.text
+    assert lookml_url in caplog.text
diff --git a/tests/test_runner.py b/tests/test_runner.py
new file mode 100644
index 0000000..da43fcc
--- /dev/null
+++ b/tests/test_runner.py
@@ -0,0 +1,102 @@
+from spectacles.runner import Runner
+
+
+def build_validation(validator):
+    return {
+        "validator": validator,
+        "status": "failed",
+        "tested": [
+            dict(model="ecommerce", explore="orders", passed=True),
+            dict(model="ecommerce", explore="sessions", passed=True),
+            dict(model="ecommerce", explore="users", passed=False),
+        ],
+        "errors": [
+            dict(
+                model="ecommerce",
+                explore="users",
+                test=None,
+                message="An error occurred",
+                metadata={},
+            )
+        ],
+    }
+
+
+def test_incremental_same_results_should_not_have_errors():
+    main = build_validation("content")
+    additional = build_validation("content")
+    incremental = Runner._incremental_results(main, additional)
+    assert incremental["status"] == "passed"
+    assert incremental["errors"] == []
+    assert incremental["tested"] == [
+        dict(model="ecommerce", explore="orders", passed=True),
+        dict(model="ecommerce", explore="sessions", passed=True),
+        dict(model="ecommerce", explore="users", passed=True),
+    ]
+
+
+def test_incremental_with_fewer_errors_than_main():
+    main = build_validation("content")
+    additional = build_validation("content")
+    additional["tested"][2]["passed"] = True
+    additional["errors"] = []
+    incremental = Runner._incremental_results(main, additional)
+    assert incremental["status"] == "passed"
+    assert incremental["errors"] == []
+    assert incremental["tested"] == [
+        dict(model="ecommerce", explore="orders", passed=True),
+        dict(model="ecommerce", explore="sessions", passed=True),
+        dict(model="ecommerce", explore="users", passed=True),
+    ]
+
+
+def test_incremental_with_more_errors_than_main():
+    main = build_validation("content")
+    additional = build_validation("content")
+    additional["tested"][1]["passed"] = False
+    extra_errors = [
+        dict(
+            model="ecommerce",
+            explore="users",
+            test=None,
+            message="Another error occurred",
+            metadata={},
+        ),
+        dict(
+            model="ecommerce",
+            explore="sessions",
+            test=None,
+            message="An error occurred",
+            metadata={},
+        ),
+    ]
+    additional["errors"].extend(extra_errors)
+    incremental = Runner._incremental_results(main, additional)
+    assert incremental["status"] == "failed"
+    assert incremental["errors"] == extra_errors
+    assert incremental["tested"] == [
+        dict(model="ecommerce", explore="orders", passed=True),
+        dict(model="ecommerce", explore="sessions", passed=False),
+        dict(model="ecommerce", explore="users", passed=False),
+    ]
+
+
+def test_incremental_with_fewer_tested_explores_than_main():
+    main = build_validation("content")
+    additional = build_validation("content")
+    _ = additional["tested"].pop(0)
+    extra_error = dict(
+        model="ecommerce",
+        explore="users",
+        test=None,
+        message="Another error occurred",
+        metadata={},
+    )
+    additional["errors"].append(extra_error)
+    incremental = Runner._incremental_results(main, additional)
+    assert incremental["status"] == "failed"
+    assert incremental["errors"] == [extra_error]
+    assert incremental["tested"] == [
+        dict(model="ecommerce", explore="sessions", passed=True),
+        dict(model="ecommerce", explore="users", passed=False),
+    ]
diff --git a/tests/test_select.py b/tests/test_select.py
index efd2a3a..5f95837 100644
--- a/tests/test_select.py
+++ b/tests/test_select.py
@@ -14,6 +14,11 @@ def test_invalid_format_should_raise_value_error():
         selector_to_pattern("explore_a")
 
 
+def test_empty_selector_should_raise_value_error():
+    with pytest.raises(ValueError):
+        is_selected("model_a", "explore_a", [], [])
+
+
 def test_select_wildcard_should_match():
     assert is_selected("model_a", "explore_a", ["*/*"], [])
     assert is_selected("model_a", "explore_a", ["model_b/explore_a", "*/*"], [])
diff --git a/tests/test_sql_validator.py b/tests/test_sql_validator.py
index 6d2c218..7b33179 100644
--- a/tests/test_sql_validator.py
+++ b/tests/test_sql_validator.py
@@ -3,7 +3,8 @@ from unittest.mock import patch, create_autospec
 import pytest
 import jsonschema
 import vcr
-from spectacles.validators import SqlValidator, Query, QueryResult
+from spectacles.validators import SqlValidator
+from spectacles.validators.sql import Query, QueryResult
 from spectacles.exceptions import SpectaclesException
 
 EXPECTED_QUERY_COUNTS = {"models": 1, "explores": 1, "dimensions": 5}
@@ -121,7 +122,6 @@ class TestValidatePass:
         validator = validator_pass[0]
         explore = validator.project.models[0].explores[0]
         assert all(dim.queried for dim in explore.dimensions if dim.ignore is False)
-        assert explore.queried is True
 
     @pytest.mark.parametrize("validator_pass", ["batch", "single"], indirect=True)
     def test_ignored_dimensions_are_not_queried(self, validator_pass):
@@ -132,7 +132,7 @@ class TestValidatePass:
     @pytest.mark.parametrize("validator_pass", ["batch"], indirect=True)
     def test_count_explores(self, validator_pass):
         validator = validator_pass[0]
-        assert validator._count_explores() == 1
+        assert validator.project.count_explores() == 1
 
     @pytest.mark.parametrize("validator_pass", ["batch", "single"], indirect=True)
     def test_results_should_conform_to_schema(self, schema, validator_pass):
@@ -162,10 +162,9 @@ class TestValidateFail:
             len(validator._query_by_task_id) == 1 + EXPECTED_QUERY_COUNTS["dimensions"]
         )
 
-    def test_should_set_errored_and_queried(self, validator_fail):
+    def test_should_set_errored(self, validator_fail):
         validator = validator_fail[0]
         assert validator.project.errored is True
-        assert validator.project.queried is True
 
     def test_running_queries_should_be_empty(self, validator_fail):
         validator = validator_fail[0]
@@ -287,7 +286,7 @@ def test_get_running_query_tasks(validator):
     assert validator.get_running_query_tasks() == ["abc", "def"]
 
 
-@patch("spectacles.validators.LookerClient.cancel_query_task")
+@patch("spectacles.validators.sql.LookerClient.cancel_query_task")
 def test_cancel_queries(mock_client_cancel, validator):
     """
     Cancelling queries should result in the same number of client calls as
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 2f2bdad..4153fba 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -39,8 +39,9 @@ def test_human_readable(elapsed, expected):
 
 
 get_detail_testcases = [
-    ("validate_sql", "SQL "),
-    ("validate_data_tests", "test "),
+    ("run_sql", "SQL "),
+    ("run_assert", "data test "),
+    ("run_content", "content "),
     ("OtherClass.validate", ""),
 ]
 
@@ -55,18 +56,26 @@ class TestLogDurationDecorator(unittest.TestCase):
     def test_log_SQL(self):
         with self.assertLogs(logger=logger, level="INFO") as cm:
             func = MagicMock()
-            func.__name__ = "validate_sql"
+            func.__name__ = "run_sql"
             decorated_func = utils.log_duration(func)
             decorated_func()
-        self.assertIn("INFO:spectacles:\nCompleted SQL validation in", cm.output[0])
+        self.assertIn("INFO:spectacles:Completed SQL validation in", cm.output[0])
 
     def test_log_assert(self):
         with self.assertLogs(logger=logger, level="INFO") as cm:
             func = MagicMock()
-            func.__name__ = "validate_data_tests"
+            func.__name__ = "run_assert"
             decorated_func = utils.log_duration(func)
             decorated_func()
-        self.assertIn("INFO:spectacles:\nCompleted test validation in", cm.output[0])
+        self.assertIn("INFO:spectacles:Completed data test validation in", cm.output[0])
+
+    def test_log_content(self):
+        with self.assertLogs(logger=logger, level="INFO") as cm:
+            func = MagicMock()
+            func.__name__ = "run_content"
+            decorated_func = utils.log_duration(func)
+            decorated_func()
+        self.assertIn("INFO:spectacles:Completed content validation in", cm.output[0])
 
     def test_log_other(self):
         with self.assertLogs(logger=logger, level="INFO") as cm:
@@ -74,4 +83,4 @@ class TestLogDurationDecorator(unittest.TestCase):
             func.__name__ = "OtherValidator.validate"
             decorated_func = utils.log_duration(func)
             decorated_func()
-        self.assertIn("INFO:spectacles:\nCompleted validation in", cm.output[0])
+        self.assertIn("INFO:spectacles:Completed validation in", cm.output[0])