Repository: spectacles-ci/spectacles

Issue:
Add a content validator
Spectacles users would benefit from an additional validator that runs Looker's Content Validation via the API. There are a number of open questions about how this should work. This validator could expose the output in two possible ways:

* All content that requires validating on that branch. This would be a simple call to the content validation endpoint.
* All content that requires validating on that branch **that doesn't** require validating on the master branch, i.e. things that this branch actually broke. This would require two calls and comparison of content between the two. 

My gut feel is that we probably want to provide both ways, possibly controlled by a flag.

Discussion:
joshtemple: This PR needs tests before it's ready for review.
codecov[bot]: # [Codecov](https://codecov.io/gh/spectacles-ci/spectacles/pull/236?src=pr&el=h1) Report
> Merging [#236](https://codecov.io/gh/spectacles-ci/spectacles/pull/236?src=pr&el=desc) into [master](https://codecov.io/gh/spectacles-ci/spectacles/commit/9d65fca537b3866724c9beb9456f332451f2e5cb&el=desc) will **decrease** coverage by `8.46%`.
> The diff coverage is `54.41%`.

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

```diff
@@            Coverage Diff             @@
##           master     #236      +/-   ##
==========================================
- Coverage   89.90%   81.43%   -8.47%     
==========================================
  Files          12       13       +1     
  Lines        1060     1223     +163     
==========================================
+ Hits          953      996      +43     
- Misses        107      227     +120     
```


| [Impacted Files](https://codecov.io/gh/spectacles-ci/spectacles/pull/236?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [spectacles/printer.py](https://codecov.io/gh/spectacles-ci/spectacles/pull/236/diff?src=pr&el=tree#diff-c3BlY3RhY2xlcy9wcmludGVyLnB5) | `72.13% <20.00%> (-4.66%)` | :arrow_down: |
| [spectacles/runner.py](https://codecov.io/gh/spectacles-ci/spectacles/pull/236/diff?src=pr&el=tree#diff-c3BlY3RhY2xlcy9ydW5uZXIucHk=) | `60.00% <32.72%> (-22.06%)` | :arrow_down: |
| [spectacles/validators.py](https://codecov.io/gh/spectacles-ci/spectacles/pull/236/diff?src=pr&el=tree#diff-c3BlY3RhY2xlcy92YWxpZGF0b3JzLnB5) | `80.66% <44.18%> (-17.74%)` | :arrow_down: |
| [spectacles/cli.py](https://codecov.io/gh/spectacles-ci/spectacles/pull/236/diff?src=pr&el=tree#diff-c3BlY3RhY2xlcy9jbGkucHk=) | `73.48% <44.82%> (-5.23%)` | :arrow_down: |
| [spectacles/exceptions.py](https://codecov.io/gh/spectacles-ci/spectacles/pull/236/diff?src=pr&el=tree#diff-c3BlY3RhY2xlcy9leGNlcHRpb25zLnB5) | `89.79% <50.00%> (-3.54%)` | :arrow_down: |
| [spectacles/utils.py](https://codecov.io/gh/spectacles-ci/spectacles/pull/236/diff?src=pr&el=tree#diff-c3BlY3RhY2xlcy91dGlscy5weQ==) | `80.43% <70.00%> (-4.94%)` | :arrow_down: |
| [spectacles/lookml.py](https://codecov.io/gh/spectacles-ci/spectacles/pull/236/diff?src=pr&el=tree#diff-c3BlY3RhY2xlcy9sb29rbWwucHk=) | `79.00% <75.67%> (-3.04%)` | :arrow_down: |
| [spectacles/client.py](https://codecov.io/gh/spectacles-ci/spectacles/pull/236/diff?src=pr&el=tree#diff-c3BlY3RhY2xlcy9jbGllbnQucHk=) | `97.07% <85.71%> (-1.06%)` | :arrow_down: |
| [spectacles/select.py](https://codecov.io/gh/spectacles-ci/spectacles/pull/236/diff?src=pr&el=tree#diff-c3BlY3RhY2xlcy9zZWxlY3QucHk=) | `94.44% <94.44%> (ø)` | |
| ... and [1 more](https://codecov.io/gh/spectacles-ci/spectacles/pull/236/diff?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/spectacles-ci/spectacles/pull/236?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/236?src=pr&el=footer). Last update [9d65fca...70cb080](https://codecov.io/gh/spectacles-ci/spectacles/pull/236?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

joshtemple: @DylanBaker, there are a few limitations to this validator in the current state:

1. If an explore is deleted, we won't be able to associate the content error to the `Project` because the errors returned by the API are not project-qualified. It is possible to fix this and display dangling errors (#240) but it will require additional dev because currently we prune the `Project` to only the models and explores that have been selected.
1. For the same reason, if there are two projects with the same model with the same explore in each, we could appropriate the error to the wrong project. This is a Looker API limitation, to my knowledge there is no way around this at the moment.
DylanBaker: @joshtemple Thanks. That makes sense. It sounds like we could solve both those issues if we could convince Looker to return the project name from that endpoint?
joshtemple: That's correct. I've created #240 and #241 to track this missing functionality and I'll make sure it's in the docs PR as caveats as well.

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/spectacles/cli.py b/spectacles/cli.py
index 134e812..aea1832 100644
--- a/spectacles/cli.py
+++ b/spectacles/cli.py
@@ -1,5 +1,6 @@
 from pathlib import Path
 import sys
+import platform
 import yaml
 import json
 from yaml.parser import ParserError
@@ -18,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):
@@ -200,6 +202,12 @@ def handle_exceptions(function: Callable) -> Callable:
 @handle_exceptions
 def main():
     """Runs main function. This is the entry point."""
+    if sys.version_info < (3, 7):
+        raise SpectaclesException(
+            name="insufficient-python-version",
+            title="Spectacles requires Python 3.7 or higher.",
+            detail=f"The current Python version is %s." % platform.python_version(),
+        )
     parser = create_parser()
     args = parser.parse_args()
     for handler in logger.handlers:
@@ -255,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(
@@ -281,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
 
 
@@ -517,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:
@@ -524,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,
@@ -571,6 +677,7 @@ def run_assert(
         logger.info("")
 
 
+@log_duration
 def run_sql(
     log_dir,
     project,
@@ -608,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,9 +738,13 @@ def run_sql(
             )
         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..01c9199 100644
--- a/spectacles/exceptions.py
+++ b/spectacles/exceptions.py
@@ -121,3 +121,27 @@ class DataTestError(ValidationError):
         super().__init__(
             model=model, explore=explore, test=None, 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_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])