diff --git a/.codecov.yml b/.codecov.yml
index c1ba1ca..32d58f1 100644
--- a/.codecov.yml
+++ b/.codecov.yml
@@ -3,7 +3,7 @@ coverage:
   status:
     project:
       default:
-        target: '20'
+        target: '40'
     patch:
       default:
         target: '20'
diff --git a/demo.sh b/demo.sh
index bf0bf4a..269b6bc 100755
--- a/demo.sh
+++ b/demo.sh
@@ -3,6 +3,8 @@
 # failure is a natural part of life
 set -e
 
+mkdir -p $(pwd)/test_data
+
 for pkg in uptasticsearch; do
 
     # The R package
diff --git a/doppel/PackageAPI.py b/doppel/PackageAPI.py
new file mode 100644
index 0000000..f77965b
--- /dev/null
+++ b/doppel/PackageAPI.py
@@ -0,0 +1,81 @@
+import json
+
+
+def _log_info(msg):
+    print(msg)
+
+
+class PackageAPI():
+    """Package API class
+
+    This class is used to hold the interface of a given package
+    being analyzed by doppel. It's comparison operators enable comparison
+    between interfaces and its standard JSON format allows this comparison
+    to happen across programming languages.
+
+    """
+
+    def __init__(self, pkg_dict: dict):
+        """Python object containing data that describes a package API
+
+        :param pkg_dict: A dictionary representation of a
+            software package, complying with the output format of
+            doppel-describe.
+
+        """
+
+        self._validate_pkg(pkg_dict)
+        self.pkg_dict = pkg_dict
+
+    @classmethod
+    def from_json(cls, filename: str):
+        """
+        Instantiate a Package object from a file.
+
+        :param filename: Name of the JSON file
+            that contains the description of the
+            target package's API.
+
+        """
+        _log_info("Creating package from {}".format(filename))
+
+        # read in output of "analyze.*" script
+        with open(filename, 'r') as f:
+            pkg_dict = json.loads(f.read())
+
+        # validate
+        return cls(pkg_dict)
+
+    def _validate_pkg(self, pkg_dict: dict):
+
+        assert isinstance(pkg_dict, dict)
+        assert pkg_dict['name'] is not None
+        assert pkg_dict['language'] is not None
+        assert pkg_dict['functions'] is not None
+        assert pkg_dict['classes'] is not None
+
+        return
+
+    def name(self):
+        return(self.pkg_dict['name'])
+
+    def num_functions(self):
+        return(len(self.function_names()))
+
+    def function_names(self):
+        return(sorted(list(self.pkg_dict['functions'].keys())))
+
+    def functions_with_args(self):
+        return(self.pkg_dict['functions'])
+
+    def num_classes(self):
+        return(len(self.class_names()))
+
+    def class_names(self):
+        return(sorted(list(self.pkg_dict['classes'].keys())))
+
+    def public_methods(self, class_name):
+        return(sorted(list(self.pkg_dict['classes'][class_name]['public_methods'].keys())))
+
+    def public_method_args(self, class_name, method_name):
+        return(list(self.pkg_dict['classes'][class_name]['public_methods'][method_name]['args']))
diff --git a/doppel/PackageCollection.py b/doppel/PackageCollection.py
index 04a8c93..7ff809e 100644
--- a/doppel/PackageCollection.py
+++ b/doppel/PackageCollection.py
@@ -1,4 +1,4 @@
-from doppel import PackageAPI
+from doppel.PackageAPI import PackageAPI
 from typing import Dict
 from typing import List
 
diff --git a/doppel/__init__.py b/doppel/__init__.py
index ed925f9..ac19ff3 100644
--- a/doppel/__init__.py
+++ b/doppel/__init__.py
@@ -3,5 +3,7 @@ __all__ = [
     'PackageCollection'
 ]
 
-from doppel.cli import PackageAPI
+from doppel.PackageAPI import PackageAPI
 from doppel.PackageCollection import PackageCollection
+from doppel.DoppelTestError import DoppelTestError
+from doppel.reporters import SimpleReporter
diff --git a/doppel/cli.py b/doppel/cli.py
index 187fa25..6fde637 100644
--- a/doppel/cli.py
+++ b/doppel/cli.py
@@ -2,86 +2,7 @@
 import click
 import json
 from doppel.reporters import SimpleReporter
-
-
-def _log_info(msg):
-    print(msg)
-
-
-class PackageAPI():
-    """Package API class
-
-    This class is used to hold the interface of a given package
-    being analyzed by doppel. It's comparison operators enable comparison
-    between interfaces and its standard JSON format allows this comparison
-    to happen across programming languages.
-
-    """
-
-    def __init__(self, pkg_dict: dict):
-        """Python object containing data that describes a package API
-
-        :param pkg_dict: A dictionary representation of a
-            software package, complying with the output format of
-            doppel-describe.
-
-        """
-
-        self._validate_pkg(pkg_dict)
-        self.pkg_dict = pkg_dict
-
-    @classmethod
-    def from_json(cls, filename: str):
-        """
-        Instantiate a Package object from a file.
-
-        :param filename: Name of the JSON file
-            that contains the description of the
-            target package's API.
-
-        """
-        _log_info("Creating package from {}".format(filename))
-
-        # read in output of "analyze.*" script
-        with open(filename, 'r') as f:
-            pkg_dict = json.loads(f.read())
-
-        # validate
-        return cls(pkg_dict)
-
-    def _validate_pkg(self, pkg_dict: dict):
-
-        assert isinstance(pkg_dict, dict)
-        assert pkg_dict['name'] is not None
-        assert pkg_dict['language'] is not None
-        assert pkg_dict['functions'] is not None
-        assert pkg_dict['classes'] is not None
-
-        return
-
-    def name(self):
-        return(self.pkg_dict['name'])
-
-    def num_functions(self):
-        return(len(self.function_names()))
-
-    def function_names(self):
-        return(list(self.pkg_dict['functions'].keys()))
-
-    def functions_with_args(self):
-        return(self.pkg_dict['functions'])
-
-    def num_classes(self):
-        return(len(self.class_names()))
-
-    def class_names(self):
-        return(list(self.pkg_dict['classes'].keys()))
-
-    def public_methods(self, class_name):
-        return(list(self.pkg_dict['classes'][class_name]['public_methods'].keys()))
-
-    def public_method_args(self, class_name, method_name):
-        return(list(self.pkg_dict['classes'][class_name]['public_methods'][method_name]['args']))
+from doppel.PackageAPI import PackageAPI
 
 
 @click.command()
diff --git a/doppel/reporters.py b/doppel/reporters.py
index d852e7e..a26838b 100644
--- a/doppel/reporters.py
+++ b/doppel/reporters.py
@@ -4,23 +4,10 @@ from sys import stdout
 from tabulate import tabulate
 from functools import reduce
 from typing import List
+from doppel.DoppelTestError import DoppelTestError
 
 
-class DoppelTestError:
-
-    def __init__(self, msg: str):
-        """
-        Custom error class used for testing issues.
-
-        :param msg: Error text to print
-        """
-        self.msg = msg
-
-    def __str__(self):
-        return("{}\n".format(self.msg))
-
-
-class OutputTable:
+class _OutputTable:
     """
     Many checks will write tabular output. Wrapping
     this in a class so the choice of format from
@@ -128,7 +115,7 @@ class SimpleReporter:
             counts.append(pkg.num_functions())
 
         # Report output
-        out = OutputTable(headers=pkg_names, rows=[counts])
+        out = _OutputTable(headers=pkg_names, rows=[counts])
         out.write()
 
         # Append errors
@@ -177,7 +164,7 @@ class SimpleReporter:
             rows += [row]
 
         # Report output
-        out = OutputTable(headers=headers, rows=rows)
+        out = _OutputTable(headers=headers, rows=rows)
         out.write()
 
         # Append errors
@@ -252,7 +239,7 @@ class SimpleReporter:
             len(all_functions)
         ))
 
-        out = OutputTable(headers=headers, rows=rows)
+        out = _OutputTable(headers=headers, rows=rows)
         out.write()
 
         # Print output
@@ -274,7 +261,7 @@ class SimpleReporter:
             counts.append(pkg.num_classes())
 
         # Report output
-        out = OutputTable(headers=names, rows=[counts])
+        out = _OutputTable(headers=names, rows=[counts])
         out.write()
 
         # Append errors
@@ -323,7 +310,7 @@ class SimpleReporter:
             rows += [row]
 
         # Report output
-        out = OutputTable(headers=headers, rows=rows)
+        out = _OutputTable(headers=headers, rows=rows)
         out.write()
 
         # Append errors

diff --git a/doppel/DoppelTestError.py b/doppel/DoppelTestError.py
new file mode 100644
index 0000000..6315acf
--- /dev/null
+++ b/doppel/DoppelTestError.py
@@ -0,0 +1,13 @@
+
+class DoppelTestError:
+
+    def __init__(self, msg: str):
+        """
+        Custom error class used for testing issues.
+
+        :param msg: Error text to print
+        """
+        self.msg = msg
+
+    def __str__(self):
+        return("{}\n".format(self.msg))
diff --git a/tests/test_package_api.py b/tests/test_package_api.py
index 117fccb..286adc1 100644
--- a/tests/test_package_api.py
+++ b/tests/test_package_api.py
@@ -1,12 +1,89 @@
 import unittest
-from doppel.cli import _log_info
+import os
+from doppel import PackageAPI
 
+TESTDATA_DIR = os.path.join('tests', 'testdata')
 
-class TestStuff(unittest.TestCase):
 
-    def test_logging(self):
+class TestPackageAPI(unittest.TestCase):
+    """
+    PackageAPI tests on simple package with
+    one class and one or two functions
+    """
+
+    py_pkg_file = os.path.join(
+        TESTDATA_DIR,
+        'python_package1.json'
+    )
+    r_pkg_file = os.path.join(
+        TESTDATA_DIR,
+        'r_package1.json'
+    )
+
+    def test_from_json_py(self):
         """
-        _log_info should work
+        PackageAPI.from_json() should work for Python packages
         """
-        _log_info('stuff')
-        self.assertTrue(True)
+        pkg = PackageAPI.from_json(self.py_pkg_file)
+
+        self.assertEqual(pkg.name(), 'boombap [python]')
+        self.assertEqual(pkg.num_functions(), 1)
+        self.assertEqual(pkg.function_names(), ['playback'])
+        self.assertEqual(
+            pkg.functions_with_args(),
+            {
+                'playback': {
+                    'args': [
+                        'bpm',
+                        'bass'
+                    ]
+                }
+            }
+        )
+        self.assertEqual(pkg.num_classes(), 1)
+        self.assertEqual(pkg.class_names(), ["LupeFiasco"])
+        self.assertEqual(pkg.public_methods("LupeFiasco"), ["coast", "~~CONSTRUCTOR~~"])
+        self.assertEqual(
+            pkg.public_method_args("LupeFiasco", "~~CONSTRUCTOR~~"),
+            ["kick", "push"]
+        )
+        self.assertEqual(
+            pkg.public_method_args("LupeFiasco", "coast"),
+            []
+        )
+
+    def test_from_json_r(self):
+        """
+        PackageAPI.from_json() should work for R packages
+        """
+        pkg = PackageAPI.from_json(self.r_pkg_file)
+
+        self.assertEqual(pkg.name(), 'boombap [r]')
+        self.assertEqual(pkg.num_functions(), 1)
+        self.assertEqual(pkg.function_names(), ['playback'])
+        self.assertEqual(
+            pkg.functions_with_args(),
+            {
+                'playback': {
+                    'args': [
+                        'bpm',
+                        'bass'
+                    ]
+                }
+            }
+        )
+        self.assertEqual(pkg.num_classes(), 1)
+        self.assertEqual(pkg.class_names(), ["LupeFiasco"])
+        self.assertEqual(pkg.public_methods("LupeFiasco"), ["coast", "words", "~~CONSTRUCTOR~~"])
+        self.assertEqual(
+            pkg.public_method_args("LupeFiasco", "~~CONSTRUCTOR~~"),
+            ["kick", "push"]
+        )
+        self.assertEqual(
+            pkg.public_method_args("LupeFiasco", "coast"),
+            []
+        )
+        self.assertEqual(
+            pkg.public_method_args("LupeFiasco", "words"),
+            ["i_said", "i_never_said"]
+        )
diff --git a/tests/test_package_collection.py b/tests/test_package_collection.py
new file mode 100644
index 0000000..5a9b668
--- /dev/null
+++ b/tests/test_package_collection.py
@@ -0,0 +1,99 @@
+import unittest
+import os
+from doppel import PackageCollection
+from doppel import PackageAPI
+
+TESTDATA_DIR = os.path.join('tests', 'testdata')
+
+
+class TestPackageAPI(unittest.TestCase):
+
+    py_pkg_file = os.path.join(
+        TESTDATA_DIR,
+        'python_package1.json'
+    )
+    r_pkg_file = os.path.join(
+        TESTDATA_DIR,
+        'r_package1.json'
+    )
+
+    def setUp(self):
+        self.pkg_collection = PackageCollection(
+            packages=[
+                PackageAPI.from_json(self.py_pkg_file),
+                PackageAPI.from_json(self.r_pkg_file)
+            ]
+        )
+
+    def test_all_classes(self):
+        """
+        PackageCollection.all_classes() should work as expected
+        for packages with identical classes
+        """
+        self.assertEqual(
+            self.pkg_collection.all_classes(),
+            ["LupeFiasco"]
+        )
+
+    def test_shared_classes(self):
+        """
+        PackageCollection.shared_classes() should work as expected
+        for packages with identical classes
+        """
+        self.assertEqual(
+            self.pkg_collection.shared_classes(),
+            ["LupeFiasco"]
+        )
+
+    def test_non_shared_classes(self):
+        """
+        PackageCollection.non_shared_classes() should work as expected
+        for packages with identical classes.
+        """
+        self.assertEqual(
+            self.pkg_collection.non_shared_classes(),
+            []
+        )
+
+    def test_all_functions(self):
+        """
+        PackageCollection.all_functions() should work as expected
+        for packages with some overlapping functions
+        """
+        self.assertEqual(
+            self.pkg_collection.all_functions(),
+            ["playback"]
+        )
+
+    def test_shared_functions(self):
+        """
+        PackageCollection.shared_functions() should work as expected
+        for packages with some overlapping functions
+        """
+        self.assertEqual(
+            self.pkg_collection.shared_functions(),
+            ["playback"]
+        )
+
+    def test_non_shared_functions(self):
+        """
+        PackageCollection.non_shared_functions() should work as expected
+        for packages with some overlapping functions
+        """
+        self.assertEqual(
+            self.pkg_collection.non_shared_functions(),
+            []
+        )
+
+    def test_shared_methods_by_class(self):
+        """
+        PackageCollection.shared_methods_by_class() should work
+        as expected for packages with slightly different
+        class methods
+        """
+        shared = self.pkg_collection.shared_methods_by_class()
+        self.assertEqual(list(shared.keys()), ["LupeFiasco"])
+        self.assertEqual(
+            sorted(shared["LupeFiasco"]),
+            sorted(["~~CONSTRUCTOR~~", "coast"])
+        )
diff --git a/tests/testdata/python_package1.json b/tests/testdata/python_package1.json
new file mode 100644
index 0000000..9a2e06c
--- /dev/null
+++ b/tests/testdata/python_package1.json
@@ -0,0 +1,27 @@
+{
+  "name": "boombap [python]",
+  "language": "python",
+  "functions": {
+    "playback": {
+      "args": [
+        "bpm",
+        "bass"
+      ]
+    }
+  },
+  "classes": {
+    "LupeFiasco": {
+      "public_methods": {
+        "~~CONSTRUCTOR~~": {
+          "args": [
+            "kick",
+            "push"
+          ]
+        },
+        "coast": {
+          "args": []
+        }
+      }
+    }
+  }
+}
diff --git a/tests/testdata/r_package1.json b/tests/testdata/r_package1.json
new file mode 100644
index 0000000..957a945
--- /dev/null
+++ b/tests/testdata/r_package1.json
@@ -0,0 +1,33 @@
+{
+  "name": "boombap [r]",
+  "language": "r",
+  "functions": {
+    "playback": {
+      "args": [
+        "bpm",
+        "bass"
+      ]
+    }
+  },
+  "classes": {
+    "LupeFiasco": {
+      "public_methods": {
+        "~~CONSTRUCTOR~~": {
+          "args": [
+            "kick",
+            "push"
+          ]
+        },
+        "coast": {
+          "args": []
+        },
+        "words": {
+          "args": [
+            "i_said",
+            "i_never_said"
+          ]
+        }
+      }
+    }
+  }
+}

Get test coverage to 40%
Doing this should also involve editing `.codecov.yml` to bump the coverage threshold for the project to 40%.
tests/test_package_api.py::TestPackageAPI::test_from_json_py tests/test_package_api.py::TestPackageAPI::test_from_json_r
tests/test_package_collection.py::TestPackageAPI::test_all_classes tests/test_package_collection.py::TestPackageAPI::test_all_functions tests/test_package_collection.py::TestPackageAPI::test_non_shared_classes tests/test_package_collection.py::TestPackageAPI::test_non_shared_functions tests/test_package_collection.py::TestPackageAPI::test_shared_classes tests/test_package_collection.py::TestPackageAPI::test_shared_functions tests/test_package_collection.py::TestPackageAPI::test_shared_methods_by_class