Revert "Remove vendored libjuju"

This reverts commit 9d18c22a0dc9e295adda50601fc5e2f45d2c9b8a.

Change-Id: I7dbf291ccd750c5f836ff80c642be492434ab3ac
Signed-off-by: Adam Israel <adam.israel@canonical.com>
diff --git a/modules/libjuju/tests/__init__.py b/modules/libjuju/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/modules/libjuju/tests/__init__.py
diff --git a/modules/libjuju/tests/base.py b/modules/libjuju/tests/base.py
new file mode 100644
index 0000000..600372c
--- /dev/null
+++ b/modules/libjuju/tests/base.py
@@ -0,0 +1,148 @@
+import inspect
+import subprocess
+import uuid
+from contextlib import contextmanager
+from pathlib import Path
+
+import mock
+from juju.client.jujudata import FileJujuData
+from juju.controller import Controller
+
+import pytest
+
+
+def is_bootstrapped():
+    try:
+        result = subprocess.run(['juju', 'switch'], stdout=subprocess.PIPE)
+        return (
+            result.returncode == 0 and
+            len(result.stdout.decode().strip()) > 0)
+    except FileNotFoundError:
+        return False
+
+
+bootstrapped = pytest.mark.skipif(
+    not is_bootstrapped(),
+    reason='bootstrapped Juju environment required')
+
+test_run_nonce = uuid.uuid4().hex[-4:]
+
+
+class CleanController():
+    """
+    Context manager that automatically connects and disconnects from
+    the currently active controller.
+
+    Note: Unlike CleanModel, this will not create a new controller for you,
+    and an active controller must already be available.
+    """
+    def __init__(self):
+        self._controller = None
+
+    async def __aenter__(self):
+        self._controller = Controller()
+        await self._controller.connect()
+        return self._controller
+
+    async def __aexit__(self, exc_type, exc, tb):
+        await self._controller.disconnect()
+
+
+class CleanModel():
+    """
+    Context manager that automatically connects to the currently active
+    controller, adds a fresh model, returns the connection to that model,
+    and automatically disconnects and cleans up the model.
+
+    The new model is also set as the current default for the controller
+    connection.
+    """
+    def __init__(self, bakery_client=None):
+        self._controller = None
+        self._model = None
+        self._model_uuid = None
+        self._bakery_client = bakery_client
+
+    async def __aenter__(self):
+        model_nonce = uuid.uuid4().hex[-4:]
+        frame = inspect.stack()[1]
+        test_name = frame.function.replace('_', '-')
+        jujudata = TestJujuData()
+        self._controller = Controller(
+            jujudata=jujudata,
+            bakery_client=self._bakery_client,
+        )
+        controller_name = jujudata.current_controller()
+        user_name = jujudata.accounts()[controller_name]['user']
+        await self._controller.connect(controller_name)
+
+        model_name = 'test-{}-{}-{}'.format(
+            test_run_nonce,
+            test_name,
+            model_nonce,
+        )
+        self._model = await self._controller.add_model(model_name)
+
+        # Change the JujuData instance so that it will return the new
+        # model as the current model name, so that we'll connect
+        # to it by default.
+        jujudata.set_model(
+            controller_name,
+            user_name + "/" + model_name,
+            self._model.info.uuid,
+        )
+
+        # save the model UUID in case test closes model
+        self._model_uuid = self._model.info.uuid
+
+        return self._model
+
+    async def __aexit__(self, exc_type, exc, tb):
+        await self._model.disconnect()
+        await self._controller.destroy_model(self._model_uuid)
+        await self._controller.disconnect()
+
+
+class TestJujuData(FileJujuData):
+    def __init__(self):
+        self.__controller_name = None
+        self.__model_name = None
+        self.__model_uuid = None
+        super().__init__()
+
+    def set_model(self, controller_name, model_name, model_uuid):
+        self.__controller_name = controller_name
+        self.__model_name = model_name
+        self.__model_uuid = model_uuid
+
+    def current_model(self, *args, **kwargs):
+        return self.__model_name or super().current_model(*args, **kwargs)
+
+    def models(self):
+        all_models = super().models()
+        if self.__model_name is None:
+            return all_models
+        all_models.setdefault(self.__controller_name, {})
+        all_models[self.__controller_name].setdefault('models', {})
+        cmodels = all_models[self.__controller_name]['models']
+        cmodels[self.__model_name] = {'uuid': self.__model_uuid}
+        return all_models
+
+
+class AsyncMock(mock.MagicMock):
+    async def __call__(self, *args, **kwargs):
+        return super().__call__(*args, **kwargs)
+
+
+@contextmanager
+def patch_file(filename):
+    """
+    "Patch" a file so that its current contents are automatically restored
+    when the context is exited.
+    """
+    filepath = Path(filename).expanduser()
+    data = filepath.read_bytes()
+    try:
+        yield
+    finally:
+        filepath.write_bytes(data)
diff --git a/modules/libjuju/tests/bundle/bundle.yaml b/modules/libjuju/tests/bundle/bundle.yaml
new file mode 100644
index 0000000..19a45ec
--- /dev/null
+++ b/modules/libjuju/tests/bundle/bundle.yaml
@@ -0,0 +1,28 @@
+series: xenial
+services:
+  wordpress:
+    charm: "cs:trusty/wordpress-2"
+    num_units: 1
+    annotations:
+      "gui-x": "339.5"
+      "gui-y": "-171"
+    to:
+      - "0"
+  mysql:
+    charm: "cs:trusty/mysql-26"
+    num_units: 1
+    annotations:
+      "gui-x": "79.5"
+      "gui-y": "-142"
+    to:
+      - "1"
+relations:
+  - - "wordpress:db"
+    - "mysql:db"
+machines:
+  "0":
+    series: trusty
+    constraints: "arch=amd64 cores=1 cpu-power=100 mem=1740 root-disk=8192"
+  "1":
+    series: trusty
+    constraints: "arch=amd64 cores=1 cpu-power=100 mem=1740 root-disk=8192"
diff --git a/modules/libjuju/tests/bundle/invalid.yaml b/modules/libjuju/tests/bundle/invalid.yaml
new file mode 100644
index 0000000..2e51c1e
--- /dev/null
+++ b/modules/libjuju/tests/bundle/invalid.yaml
@@ -0,0 +1,7 @@
+applications:
+  myapp:
+    charm: cs:xenial/ubuntu-0
+    num_units: 1
+    to:
+      - 0
+      - 0
diff --git a/modules/libjuju/tests/bundle/mini-bundle.yaml b/modules/libjuju/tests/bundle/mini-bundle.yaml
new file mode 100644
index 0000000..84d9a61
--- /dev/null
+++ b/modules/libjuju/tests/bundle/mini-bundle.yaml
@@ -0,0 +1,9 @@
+series: xenial
+applications:
+  dummy-sink:
+    charm: cs:~juju-qa/dummy-sink
+    num_units: 1
+  dummy-subordinate:
+    charm: cs:~juju-qa/dummy-subordinate
+relations:
+  - ['dummy-sink', 'dummy-subordinate']
diff --git a/modules/libjuju/tests/charm/metadata.yaml b/modules/libjuju/tests/charm/metadata.yaml
new file mode 100644
index 0000000..74eab3d
--- /dev/null
+++ b/modules/libjuju/tests/charm/metadata.yaml
@@ -0,0 +1,5 @@
+name: charm
+series: ["xenial"]
+summary: "test"
+description: "test"
+maintainers: ["test"]
diff --git a/modules/libjuju/tests/integration/__init__.py b/modules/libjuju/tests/integration/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/modules/libjuju/tests/integration/__init__.py
diff --git a/modules/libjuju/tests/integration/bundle/bundle-resource-rev.yaml b/modules/libjuju/tests/integration/bundle/bundle-resource-rev.yaml
new file mode 100644
index 0000000..c8feb83
--- /dev/null
+++ b/modules/libjuju/tests/integration/bundle/bundle-resource-rev.yaml
@@ -0,0 +1,7 @@
+series: xenial
+services:
+  ghost:
+    charm: "cs:ghost-19"
+    num_units: 1
+    resources:
+      ghost-stable: 11
diff --git a/modules/libjuju/tests/integration/bundle/bundle.yaml b/modules/libjuju/tests/integration/bundle/bundle.yaml
new file mode 100644
index 0000000..d0245c5
--- /dev/null
+++ b/modules/libjuju/tests/integration/bundle/bundle.yaml
@@ -0,0 +1,12 @@
+series: xenial
+services:
+  ghost:
+    charm: "cs:ghost-19"
+    num_units: 1
+  mysql:
+    charm: "cs:trusty/mysql-57"
+    num_units: 1
+  test:
+    charm: "./tests/integration/charm"
+relations:
+  - ["ghost", "mysql"]
diff --git a/modules/libjuju/tests/integration/cert.pem b/modules/libjuju/tests/integration/cert.pem
new file mode 100644
index 0000000..684029e
--- /dev/null
+++ b/modules/libjuju/tests/integration/cert.pem
@@ -0,0 +1,33 @@
+-----BEGIN CERTIFICATE-----
+MIIFxDCCA6ygAwIBAgIJAPBHQW1VVLhYMA0GCSqGSIb3DQEBCwUAMHcxCzAJBgNV
+BAYTAlVTMRcwFQYDVQQIDA5Ob3J0aCBDYXJvbGluYTEQMA4GA1UEBwwHUmFsZWln
+aDESMBAGA1UECgwJQ2Fub25pY2FsMSkwJwYDVQQDDCBweXRob24tbGlianVqdS5p
+bnRlZ3JhdGlvbi50ZXN0czAeFw0xODEyMTEyMDQyMThaFw0xOTEyMTEyMDQyMTha
+MHcxCzAJBgNVBAYTAlVTMRcwFQYDVQQIDA5Ob3J0aCBDYXJvbGluYTEQMA4GA1UE
+BwwHUmFsZWlnaDESMBAGA1UECgwJQ2Fub25pY2FsMSkwJwYDVQQDDCBweXRob24t
+bGlianVqdS5pbnRlZ3JhdGlvbi50ZXN0czCCAiIwDQYJKoZIhvcNAQEBBQADggIP
+ADCCAgoCggIBANkkfNI4Q3eIIrXZF3xxnD9Bbp06jPeoEokUSrC98L5XwCtxs9Ma
+fWPqiSukPq3A0pPcRobFOu5o+pu6+1pFjcmtKICsp+CwHV52+IfozvNlhRMUFPb2
+IxU86tgpMcBAM5wLz85DoNSu4EjOku0rB8Z0GMfvvuc7aYtDQoKJSScHGWWFTaJT
+mtXmmKKSPtyPZ5PCQOv2Qmg5ujs4RS3R8dq4WRAd5F8YzRBgmtPYRrjwgEjD7GTq
+Xlt+COqn5+RgWFIH0pQ1xHkslBzBByzsyjLJPdV/ugNIZk6GNOzLAw3ynl/9xGGh
+RAOrBsKVQPs3liZoGXVqKxHyNgWh8V58TgiFDuRZiZwQDV6cRkxOkGQIXohkS7uC
+5OXrAsyyVPrRoWfQMggxM1jH3H+93PoRqEsLypLxw53h9Seep4tOggw/hgOolLFg
+diAT/IU3yhESXnXpAA8G2rDKO32hw+vWuaGCbH6bShb+9ZfkAmLUiSVm9ClbkZRQ
+IaXmtBwsLS2IBxW51jrjSuXvqlBZc01pIUC2CT7aTrbftpA+w6i1bZpqjCukUSAx
+t4dRjzL/h/drp5xxlqXOZL9yjYl8vFul4LFKkpkJGIKESEksxULZR/3b+WA7oyiA
+Ph9UgnKU730dKVSfWbi/0tsCA/4HKIJ9qucCrfxyL8hizJ+1w9NZJ3ZbAgMBAAGj
+UzBRMB0GA1UdDgQWBBSv/7Fraw7XviWwlnPGc+CTJA0rKjAfBgNVHSMEGDAWgBSv
+/7Fraw7XviWwlnPGc+CTJA0rKjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEB
+CwUAA4ICAQCluQ7qFgNoGPkAkdoDXN26OhQcfrCmjvPufurs8Fc4Yxumupz01Df+
+zu3J2pG+tfThi37sRZsxN/jhtZOVgaXPSgpgB8Tz16YCK3KTEHXil/i6wp4JGmnQ
+VsOo8cGLEvcmFg98/l2Tu31IXaSm7Fc/VxVB0fhzvIa0xG+osU3Dy4GWhDQrkkJ8
+MErTvEAb8QnY1y20ThPAZ5StBZ/ZLwPksuzkvJwGN8FI00k1RCznDNArzKouuZZt
+JdfjnFtT+nrP0XSAlphfI0o7Gbm6jqVLPIekp8iPJuAmk55biZ9rAJBu8DsHtBpQ
+cbpH+84y/xaC9svPgD9Un6dDDYXQ4QZL393oZW1RM4apR9gOz/8NxW2bJyPVxA3M
+nmHphZjyGrOLN/QjrZV93Dv65SIivzT2zYEztvK4rjui31J3VDeNEe7uMFGq5Pmq
+7HRrcPY2sMSOHkzOTFmd5qwaPD9EzkM5UT5YJDEE7YUjkR8LlxP5Xvo82HXxKl8D
+8Tk09HuBMWg69QjuVThBNJSWpIxTbQEfm/2j+fm1L1kE9N7+97y69eDTNdOxJK5D
+zaKoAjgoneZZzZN6fblfbQuAsDdJ2xLR0DTBlxA9Gv7OYbUpXm3eAyfYL4AmqvHI
+HtfgPeSH2+wQVteiVa7q0BIto75XE3m8+xIOQsAUEb04fcacEpKZlw==
+-----END CERTIFICATE-----
diff --git a/modules/libjuju/tests/integration/charm/metadata.yaml b/modules/libjuju/tests/integration/charm/metadata.yaml
new file mode 100644
index 0000000..74eab3d
--- /dev/null
+++ b/modules/libjuju/tests/integration/charm/metadata.yaml
@@ -0,0 +1,5 @@
+name: charm
+series: ["xenial"]
+summary: "test"
+description: "test"
+maintainers: ["test"]
diff --git a/modules/libjuju/tests/integration/key.pem b/modules/libjuju/tests/integration/key.pem
new file mode 100644
index 0000000..664123e
--- /dev/null
+++ b/modules/libjuju/tests/integration/key.pem
@@ -0,0 +1,52 @@
+-----BEGIN PRIVATE KEY-----
+MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQDZJHzSOEN3iCK1
+2Rd8cZw/QW6dOoz3qBKJFEqwvfC+V8ArcbPTGn1j6okrpD6twNKT3EaGxTruaPqb
+uvtaRY3JrSiArKfgsB1edviH6M7zZYUTFBT29iMVPOrYKTHAQDOcC8/OQ6DUruBI
+zpLtKwfGdBjH777nO2mLQ0KCiUknBxllhU2iU5rV5piikj7cj2eTwkDr9kJoObo7
+OEUt0fHauFkQHeRfGM0QYJrT2Ea48IBIw+xk6l5bfgjqp+fkYFhSB9KUNcR5LJQc
+wQcs7MoyyT3Vf7oDSGZOhjTsywMN8p5f/cRhoUQDqwbClUD7N5YmaBl1aisR8jYF
+ofFefE4IhQ7kWYmcEA1enEZMTpBkCF6IZEu7guTl6wLMslT60aFn0DIIMTNYx9x/
+vdz6EahLC8qS8cOd4fUnnqeLToIMP4YDqJSxYHYgE/yFN8oREl516QAPBtqwyjt9
+ocPr1rmhgmx+m0oW/vWX5AJi1IklZvQpW5GUUCGl5rQcLC0tiAcVudY640rl76pQ
+WXNNaSFAtgk+2k6237aQPsOotW2aaowrpFEgMbeHUY8y/4f3a6eccZalzmS/co2J
+fLxbpeCxSpKZCRiChEhJLMVC2Uf92/lgO6MogD4fVIJylO99HSlUn1m4v9LbAgP+
+ByiCfarnAq38ci/IYsyftcPTWSd2WwIDAQABAoICAFbe9Rz5K2ynxxMvbej4XsUj
+vUgjw3/U+s1ik9sPsj/ERXpb+9BJ+b4+d3BBPl4vFU/YQVLrlv8IerJQ5PwhdW8o
+2lpYOLV4X9eKCzX8WscfZ1TRpO2EXVbCz0V5fZDnXn5gb1uazL4p1ErscfV2UJ8B
+lWRvstU5fKkdWH92wxBdE7j80qlNf1Vx8sCfd4yvxoVjoquEEt81sR6+DVcedf7F
+38PF4bZ16pxRub9k+C5G8VurHmjlJqi9zH1sfSZtsQfoX0OyGw9LWVoDk4ZSmTYm
+Mpm2hsmHbn6dzJCrS2aKGPhYQve4F8jL5GF2as/WVji5Tu4dcmu0lg480p61ZlXf
+Q58qw4uXMU2KfiuLKyTV8MlmktMhDiHAmzAbIXskdmV2BNlIwyudsVXT1QD0BKH5
+I+8edW1OPLK72/kt9VT1paRbTiCL0NWk99QCL1VxPB4FupQQm1Hk2KnMrUfjFUOm
+9cuOP1S0HcHCMHN4+y0UCmn3phrWmfI/KlDZCxmSd8gvNwKkPSW5ONuYmZlpE5Wm
+s7FwRoAGGvSsjXOC3N6m48b0dQNLnMvSXQIRiDbnTzChNJ3q7y9u0o/dqATCtelx
+S7ouVKxHqSdy9G8WK0XQAFqny2dcoHZiiuxWboF9p1zEia43pdFVy/7g08KjSBQ1
+vzVGhUDYtquqq0MociWBAoIBAQD6C8ZDBU8VWwOIPL+zX8/4MNp0PfcJrOwk1vF0
+J0aPRRvqx0g6yhzjrCtWNrQnHV6Oc9FBg/NPAthAAQWYEwQr77RJWRtle/n7cH2A
+qFkAL0nYokti7TdFMe82a8hlnuo9GQl340t95j03/HDAtrqQ0xFi32Rpp54MgHLU
+FW2AYTv7CLV18p7yHIMdn0ESORPxlc/hYuuyL6ZPm/7DF/bbieY1+ZVAzn1ls17m
+dQctagpInRC2EDL17lQK86Mh7PGdVTIaJMFvzdyuIn8qswe1n3FA0OCL7VBfMjE8
+caUZ3kfGfeNKtkUvGALoXploZhDTmfDs5XQFIoMvwi87lClBAoIBAQDeUCQMyPUC
+TcfDKmeKAiGjm4Amtb+rvA3SbPSJvteOaohyqKFswUf9fNCrJim1QVpQ+PtArlAx
+WK9X8g44+OOWx2moLxlptzjzsYEXJYzUtIjuFE10xiGJEvthmWAZ+EB7Gexu7696
+M4LcWkAivCAwcM5e2Gr3DtMMS4E9fxNZfEn7RNFtTs6Cit/R2VITlbYnECSU13FY
+DI37g8umCR01pTZ/7VVUwVMeq/irFKwTfkHoF2Fyxbp8CT91ydRsYCBs395xcEH1
+2rblBXUlGj01eVZtTX2HVSS78o/ak+Q3WJQvh1Hds8yS7QdirkOpmGGzvwpIhjzB
+ztHsgjbFt3ybAoIBAA1HAsgcSA7CPnXFhAhqVgi/z1nM0Ila/U8XesrIKx8AdHML
+EfLNOKt+QO7bCMXq8VJvI/VupETVydXcOAfTOq16lQAwExxYcPXBC2kBh3hTCoDO
+XWJrZjvuYt1o68M5pQaJhc8v6ppM14NZjEMvcMiv7IRriFFz7RiM2YwZdy8R+rVh
+yQDyWS5SBURVaIcnML/rTJaTQiC8FwCzL9v8MceGkwrareo7DL2RwMBMBo2Ky/D/
+JhwE0C/u79eFCGyMwGeyVm689OiS7dzxR/9kckxaoxDmBoZnm5TyfVrQTgwJmZYY
+qTEWbKYLiFv+afb5NHuH+RsbNAXxxzWKAigPvgECggEAYD8H7HUQBchQxMDWBJy5
+nZBT4e5rpdkLjt9W20/BGMosep9hC6l+FlN0L7Sc9/jsNgQlGrKcy1Be0U9dMvMl
+7QA2UPbbJLaLNI3TmobKOshSQ+iMRBMHL8YFCRMS1QtyNxlZEAo6yUgFzopQG/mg
+YfhkkBFX9c/4NOl3cX1TjjlN+jeoB4/HviKLldllPE9jhfPqMno3euwsiAheIWru
+t2vodWf1unTcHHpNdRvFB8dwlx+QM9VA0DRcwgz4J1dSknA1aJ02IU9oQSyks8Rx
+XXZDoZybzPxio+/2saW3dvKlbRJDsh0GY1G1EdbqOkFbgyshM5bSNQHqRl91gRHY
+IwKCAQEApF790G7J1IgNrLBxLlzEkVcv2Az8QDHviC8MxdVerGj62E5fGUb44Z+p
+SM2vjjtL7yeTPuH0K7bbVtDMZoj3l81BHY6OnmFTLPSlMnUYXs7aVjbIK3EMQtHa
+EyNOiwNtIOAUMqQ3C/KXWxdO0J8/kY6b7cvZwj5zDLYt+4j8BN8w8EL0Kgxw9Bd/
+DL++Nz3IqoOoPF0aK7hVTklVzONIdaGL2OjlyESKZOWJR1pypLC9+nUAAFbtPN5Q
+8xyG3Rqoo7j697M50LP4ZMCE8WK/uZth4LPU9KWJAJ3FHrKki0qB1zbYXS4OMFbj
+hg+Fb1z8af0WL9PCAwsWQMTaSx88tQ==
+-----END PRIVATE KEY-----
diff --git a/modules/libjuju/tests/integration/test_application.py b/modules/libjuju/tests/integration/test_application.py
new file mode 100644
index 0000000..b705832
--- /dev/null
+++ b/modules/libjuju/tests/integration/test_application.py
@@ -0,0 +1,134 @@
+import asyncio
+
+import pytest
+
+from .. import base
+
+MB = 1
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_action(event_loop):
+    async with base.CleanModel() as model:
+        ubuntu_app = await model.deploy(
+            'percona-cluster',
+            application_name='mysql',
+            series='xenial',
+            channel='stable',
+            config={
+                'tuning-level': 'safest',
+            },
+            constraints={
+                'mem': 256 * MB,
+            },
+        )
+
+        # update and check app config
+        await ubuntu_app.set_config({'tuning-level': 'fast'})
+        config = await ubuntu_app.get_config()
+        assert config['tuning-level']['value'] == 'fast'
+
+        # Restore config back to default
+        await ubuntu_app.reset_config(['tuning-level'])
+        config = await ubuntu_app.get_config()
+        assert config['tuning-level']['value'] == 'safest'
+
+        # update and check app constraints
+        await ubuntu_app.set_constraints({'mem': 512 * MB})
+        constraints = await ubuntu_app.get_constraints()
+        assert constraints['mem'] == 512 * MB
+
+        # check action definitions
+        actions = await ubuntu_app.get_actions()
+        assert 'backup' in actions.keys()
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_add_units(event_loop):
+    from juju.unit import Unit
+
+    async with base.CleanModel() as model:
+        app = await model.deploy(
+            'ubuntu-0',
+            application_name='ubuntu',
+            series='trusty',
+            channel='stable',
+        )
+        units = await app.add_units(count=2)
+
+        assert len(units) == 2
+        for unit in units:
+            assert isinstance(unit, Unit)
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_upgrade_charm(event_loop):
+    async with base.CleanModel() as model:
+        app = await model.deploy('ubuntu-0')
+        assert app.data['charm-url'] == 'cs:ubuntu-0'
+        await app.upgrade_charm()
+        assert app.data['charm-url'].startswith('cs:ubuntu-')
+        assert app.data['charm-url'] != 'cs:ubuntu-0'
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_upgrade_charm_channel(event_loop):
+    async with base.CleanModel() as model:
+        app = await model.deploy('ubuntu-0')
+        assert app.data['charm-url'] == 'cs:ubuntu-0'
+        await app.upgrade_charm(channel='stable')
+        assert app.data['charm-url'].startswith('cs:ubuntu-')
+        assert app.data['charm-url'] != 'cs:ubuntu-0'
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_upgrade_charm_revision(event_loop):
+    async with base.CleanModel() as model:
+        app = await model.deploy('ubuntu-0')
+        assert app.data['charm-url'] == 'cs:ubuntu-0'
+        await app.upgrade_charm(revision=8)
+        assert app.data['charm-url'] == 'cs:ubuntu-8'
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_upgrade_charm_switch(event_loop):
+    async with base.CleanModel() as model:
+        app = await model.deploy('ubuntu-0')
+        assert app.data['charm-url'] == 'cs:ubuntu-0'
+        await app.upgrade_charm(switch='ubuntu-8')
+        assert app.data['charm-url'] == 'cs:ubuntu-8'
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_upgrade_charm_resource(event_loop):
+    async with base.CleanModel() as model:
+        app = await model.deploy('cs:~cynerva/upgrade-charm-resource-test-1')
+
+        def units_ready():
+            if not app.units:
+                return False
+            unit = app.units[0]
+            return unit.workload_status == 'active' and \
+                unit.agent_status == 'idle'
+
+        await asyncio.wait_for(model.block_until(units_ready), timeout=480)
+        unit = app.units[0]
+        expected_message = 'I have no resource.'
+        assert unit.workload_status_message == expected_message
+
+        await app.upgrade_charm(revision=2)
+        await asyncio.wait_for(
+            model.block_until(
+                lambda: unit.workload_status_message != 'I have no resource.'
+            ),
+            timeout=60
+        )
+        expected_message = 'My resource: I am the resource.'
+        assert app.units[0].workload_status_message == expected_message
diff --git a/modules/libjuju/tests/integration/test_client.py b/modules/libjuju/tests/integration/test_client.py
new file mode 100644
index 0000000..240c471
--- /dev/null
+++ b/modules/libjuju/tests/integration/test_client.py
@@ -0,0 +1,21 @@
+from juju.client import client
+
+import pytest
+
+from .. import base
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_user_info(event_loop):
+    async with base.CleanModel() as model:
+        controller_conn = await model.connection().controller()
+
+        um = client.UserManagerFacade.from_connection(controller_conn)
+        result = await um.UserInfo(
+            [client.Entity('user-admin')], True)
+        await controller_conn.close()
+
+        assert isinstance(result, client.UserInfoResults)
+        for r in result.results:
+            assert isinstance(r, client.UserInfoResult)
diff --git a/modules/libjuju/tests/integration/test_connection.py b/modules/libjuju/tests/integration/test_connection.py
new file mode 100644
index 0000000..6647a03
--- /dev/null
+++ b/modules/libjuju/tests/integration/test_connection.py
@@ -0,0 +1,236 @@
+import asyncio
+import http
+import logging
+import socket
+import ssl
+from contextlib import closing
+from pathlib import Path
+
+from juju.client import client
+from juju.client.connection import Connection
+from juju.controller import Controller
+from juju.utils import run_with_interrupt
+
+import pytest
+import websockets
+
+from .. import base
+
+
+logger = logging.getLogger(__name__)
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_monitor(event_loop):
+    async with base.CleanModel() as model:
+        conn = model.connection()
+        assert conn.monitor.status == 'connected'
+        await conn.close()
+
+        assert conn.monitor.status == 'disconnected'
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_monitor_catches_error(event_loop):
+
+    async with base.CleanModel() as model:
+        conn = model.connection()
+
+        assert conn.monitor.status == 'connected'
+        try:
+            async with conn.monitor.reconnecting:
+                await conn.ws.close()
+                await asyncio.sleep(1)
+                assert conn.monitor.status == 'error'
+        finally:
+            await conn.close()
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_full_status(event_loop):
+    async with base.CleanModel() as model:
+        await model.deploy(
+            'ubuntu-0',
+            application_name='ubuntu',
+            series='trusty',
+            channel='stable',
+        )
+
+        c = client.ClientFacade.from_connection(model.connection())
+
+        await c.FullStatus(None)
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_reconnect(event_loop):
+    async with base.CleanModel() as model:
+        kwargs = model.connection().connect_params()
+        conn = await Connection.connect(**kwargs)
+        try:
+            await asyncio.sleep(0.1)
+            assert conn.is_open
+            await conn.ws.close()
+            assert not conn.is_open
+            await model.block_until(lambda: conn.is_open, timeout=3)
+        finally:
+            await conn.close()
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_redirect(event_loop):
+    controller = Controller()
+    await controller.connect()
+    kwargs = controller.connection().connect_params()
+    await controller.disconnect()
+
+    # websockets.server.logger.setLevel(logging.DEBUG)
+    # websockets.client.logger.setLevel(logging.DEBUG)
+    # # websockets.protocol.logger.setLevel(logging.DEBUG)
+    # logger.setLevel(logging.DEBUG)
+
+    destination = 'wss://{}/api'.format(kwargs['endpoint'])
+    redirect_statuses = [
+        http.HTTPStatus.MOVED_PERMANENTLY,
+        http.HTTPStatus.FOUND,
+        http.HTTPStatus.SEE_OTHER,
+        http.HTTPStatus.TEMPORARY_REDIRECT,
+        http.HTTPStatus.PERMANENT_REDIRECT,
+    ]
+    test_server_cert = Path(__file__).with_name('cert.pem')
+    kwargs['cacert'] += '\n' + test_server_cert.read_text()
+    server = RedirectServer(destination, event_loop)
+    try:
+        for status in redirect_statuses:
+            logger.debug('test: starting {}'.format(status))
+            server.start(status)
+            await run_with_interrupt(server.running.wait(),
+                                     server.terminated)
+            if server.exception:
+                raise server.exception
+            assert not server.terminated.is_set()
+            logger.debug('test: started')
+            kwargs_copy = dict(kwargs,
+                               endpoint='localhost:{}'.format(server.port))
+            logger.debug('test: connecting')
+            conn = await Connection.connect(**kwargs_copy)
+            logger.debug('test: connected')
+            await conn.close()
+            logger.debug('test: stopping')
+            server.stop()
+            await server.stopped.wait()
+            logger.debug('test: stopped')
+    finally:
+        server.terminate()
+        await server.terminated.wait()
+
+
+class RedirectServer:
+    def __init__(self, destination, loop):
+        self.destination = destination
+        self.loop = loop
+        self._start = asyncio.Event()
+        self._stop = asyncio.Event()
+        self._terminate = asyncio.Event()
+        self.running = asyncio.Event()
+        self.stopped = asyncio.Event()
+        self.terminated = asyncio.Event()
+        if hasattr(ssl, 'PROTOCOL_TLS_SERVER'):
+            # python 3.6+
+            protocol = ssl.PROTOCOL_TLS_SERVER
+        elif hasattr(ssl, 'PROTOCOL_TLS'):
+            # python 3.5.3+
+            protocol = ssl.PROTOCOL_TLS
+        else:
+            # python 3.5.2
+            protocol = ssl.PROTOCOL_TLSv1_2
+        self.ssl_context = ssl.SSLContext(protocol)
+        crt_file = Path(__file__).with_name('cert.pem')
+        key_file = Path(__file__).with_name('key.pem')
+        self.ssl_context.load_cert_chain(str(crt_file), str(key_file))
+        self.status = None
+        self.port = None
+        self._task = self.loop.create_task(self.run())
+
+    def start(self, status):
+        self.status = status
+        self.port = self._find_free_port()
+        self._start.set()
+
+    def stop(self):
+        self._stop.set()
+
+    def terminate(self):
+        self._terminate.set()
+        self.stop()
+
+    @property
+    def exception(self):
+        try:
+            return self._task.exception()
+        except (asyncio.CancelledError, asyncio.InvalidStateError):
+            return None
+
+    async def run(self):
+        logger.debug('server: active')
+
+        async def hello(websocket, path):
+            await websocket.send('hello')
+
+        async def redirect(path, request_headers):
+            return self.status, {'Location': self.destination}, b""
+
+        try:
+            while not self._terminate.is_set():
+                await run_with_interrupt(self._start.wait(),
+                                         self._terminate,
+                                         loop=self.loop)
+                if self._terminate.is_set():
+                    break
+                self._start.clear()
+                logger.debug('server: starting {}'.format(self.status))
+                try:
+                    async with websockets.serve(ws_handler=hello,
+                                                process_request=redirect,
+                                                host='localhost',
+                                                port=self.port,
+                                                ssl=self.ssl_context,
+                                                loop=self.loop):
+                        self.stopped.clear()
+                        self.running.set()
+                        logger.debug('server: started')
+                        while not self._stop.is_set():
+                            await run_with_interrupt(
+                                asyncio.sleep(1, loop=self.loop),
+                                self._stop,
+                                loop=self.loop)
+                            logger.debug('server: tick')
+                        logger.debug('server: stopping')
+                except asyncio.CancelledError:
+                    break
+                finally:
+                    self.stopped.set()
+                    self._stop.clear()
+                    self.running.clear()
+                    logger.debug('server: stopped')
+            logger.debug('server: terminating')
+        except asyncio.CancelledError:
+            pass
+        finally:
+            self._start.clear()
+            self._stop.clear()
+            self._terminate.clear()
+            self.stopped.set()
+            self.running.clear()
+            self.terminated.set()
+            logger.debug('server: terminated')
+
+    def _find_free_port(self):
+        with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
+            s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+            s.bind(('', 0))
+            return s.getsockname()[1]
diff --git a/modules/libjuju/tests/integration/test_controller.py b/modules/libjuju/tests/integration/test_controller.py
new file mode 100644
index 0000000..6423a98
--- /dev/null
+++ b/modules/libjuju/tests/integration/test_controller.py
@@ -0,0 +1,228 @@
+import asyncio
+import subprocess
+import uuid
+
+from juju.client.connection import Connection
+from juju.client.jujudata import FileJujuData
+from juju.controller import Controller
+from juju.errors import JujuAPIError
+
+import pytest
+
+from .. import base
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_add_remove_user(event_loop):
+    async with base.CleanController() as controller:
+        username = 'test{}'.format(uuid.uuid4())
+        user = await controller.get_user(username)
+        assert user is None
+        user = await controller.add_user(username)
+        assert user is not None
+        assert user.secret_key is not None
+        assert user.username == username
+        users = await controller.get_users()
+        assert any(u.username == username for u in users)
+        await controller.remove_user(username)
+        user = await controller.get_user(username)
+        assert user is None
+        users = await controller.get_users()
+        assert not any(u.username == username for u in users)
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_disable_enable_user(event_loop):
+    async with base.CleanController() as controller:
+        username = 'test-disable{}'.format(uuid.uuid4())
+        user = await controller.add_user(username)
+
+        await user.disable()
+        assert not user.enabled
+        assert user.disabled
+
+        fresh = await controller.get_user(username)  # fetch fresh copy
+        assert not fresh.enabled
+        assert fresh.disabled
+
+        await user.enable()
+        assert user.enabled
+        assert not user.disabled
+
+        fresh = await controller.get_user(username)  # fetch fresh copy
+        assert fresh.enabled
+        assert not fresh.disabled
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_change_user_password(event_loop):
+    async with base.CleanController() as controller:
+        username = 'test-password{}'.format(uuid.uuid4())
+        user = await controller.add_user(username)
+        await user.set_password('password')
+        # Check that we can connect with the new password.
+        new_connection = None
+        try:
+            kwargs = controller.connection().connect_params()
+            kwargs['username'] = username
+            kwargs['password'] = 'password'
+            new_connection = await Connection.connect(**kwargs)
+        except JujuAPIError:
+            raise AssertionError('Unable to connect with new password')
+        finally:
+            if new_connection:
+                await new_connection.close()
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_reset_user_password(event_loop):
+    async with base.CleanController() as controller:
+        username = 'test{}'.format(uuid.uuid4())
+        user = await controller.add_user(username)
+        origin_secret_key = user.secret_key
+        await user.set_password('password')
+        await controller.reset_user_password(username)
+        user = await controller.get_user(username)
+        new_secret_key = user.secret_key
+        # Check secret key is different after the reset.
+        assert origin_secret_key != new_secret_key
+        # Check that we can't connect with the old password.
+        new_connection = None
+        try:
+            kwargs = controller.connection().connect_params()
+            kwargs['username'] = username
+            kwargs['password'] = 'password'
+            new_connection = await Connection.connect(**kwargs)
+        except JujuAPIError:
+            pass
+        finally:
+            # No connection with old password
+            assert new_connection is None
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_grant_revoke(event_loop):
+    async with base.CleanController() as controller:
+        username = 'test-grant{}'.format(uuid.uuid4())
+        user = await controller.add_user(username)
+        await user.grant('superuser')
+        assert user.access == 'superuser'
+        fresh = await controller.get_user(username)  # fetch fresh copy
+        assert fresh.access == 'superuser'
+        await user.grant('login')  # already has 'superuser', so no-op
+        assert user.access == 'superuser'
+        fresh = await controller.get_user(username)  # fetch fresh copy
+        assert fresh.access == 'superuser'
+        await user.revoke()
+        assert user.access == ''
+        fresh = await controller.get_user(username)  # fetch fresh copy
+        assert fresh.access == ''
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_list_models(event_loop):
+    async with base.CleanController() as controller:
+        async with base.CleanModel() as model:
+            result = await controller.list_models()
+            assert model.info.name in result
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_get_model(event_loop):
+    async with base.CleanController() as controller:
+        by_name, by_uuid = None, None
+        model_name = 'test-{}'.format(uuid.uuid4())
+        model = await controller.add_model(model_name)
+        model_uuid = model.info.uuid
+        await model.disconnect()
+        try:
+            by_name = await controller.get_model(model_name)
+            by_uuid = await controller.get_model(model_uuid)
+            assert by_name.info.name == model_name
+            assert by_name.info.uuid == model_uuid
+            assert by_uuid.info.name == model_name
+            assert by_uuid.info.uuid == model_uuid
+        finally:
+            if by_name:
+                await by_name.disconnect()
+            if by_uuid:
+                await by_uuid.disconnect()
+            await controller.destroy_model(model_name)
+
+
+async def _wait_for_model(controller, model_name):
+    while model_name not in await controller.list_models():
+        await asyncio.sleep(0.5, loop=controller.loop)
+
+
+async def _wait_for_model_gone(controller, model_name):
+    while model_name in await controller.list_models():
+        await asyncio.sleep(0.5, loop=controller.loop)
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_destroy_model_by_name(event_loop):
+    async with base.CleanController() as controller:
+        model_name = 'test-{}'.format(uuid.uuid4())
+        model = await controller.add_model(model_name)
+        await model.disconnect()
+        await asyncio.wait_for(_wait_for_model(controller,
+                                               model_name),
+                               timeout=60)
+        await controller.destroy_model(model_name)
+        await asyncio.wait_for(_wait_for_model_gone(controller,
+                                                    model_name),
+                               timeout=60)
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_add_destroy_model_by_uuid(event_loop):
+    async with base.CleanController() as controller:
+        model_name = 'test-{}'.format(uuid.uuid4())
+        model = await controller.add_model(model_name)
+        model_uuid = model.info.uuid
+        await model.disconnect()
+        await asyncio.wait_for(_wait_for_model(controller,
+                                               model_name),
+                               timeout=60)
+        await controller.destroy_model(model_uuid)
+        await asyncio.wait_for(_wait_for_model_gone(controller,
+                                                    model_name),
+                               timeout=60)
+
+
+# this test must be run serially because it modifies the login password
+@pytest.mark.serial
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_macaroon_auth(event_loop):
+    jujudata = FileJujuData()
+    account = jujudata.accounts()[jujudata.current_controller()]
+    with base.patch_file('~/.local/share/juju/accounts.yaml'):
+        if 'password' in account:
+            # force macaroon auth by "changing" password to current password
+            result = subprocess.run(
+                ['juju', 'change-user-password'],
+                input='{0}\n{0}\n'.format(account['password']),
+                universal_newlines=True,
+                stderr=subprocess.PIPE)
+            assert result.returncode == 0, ('Failed to change password: '
+                                            '{}'.format(result.stderr))
+        controller = Controller()
+        try:
+            await controller.connect()
+            assert controller.is_connected()
+        finally:
+            if controller.is_connected():
+                await controller.disconnect()
+        async with base.CleanModel():
+            pass  # create and login to model works
diff --git a/modules/libjuju/tests/integration/test_errors.py b/modules/libjuju/tests/integration/test_errors.py
new file mode 100644
index 0000000..b10dd06
--- /dev/null
+++ b/modules/libjuju/tests/integration/test_errors.py
@@ -0,0 +1,68 @@
+import pytest
+
+from .. import base
+
+MB = 1
+GB = 1024
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_juju_api_error(event_loop):
+    '''
+    Verify that we raise a JujuAPIError for responses with an error in
+    a top level key (for completely invalid requests).
+
+    '''
+    from juju.errors import JujuAPIError
+
+    async with base.CleanModel() as model:
+        with pytest.raises(JujuAPIError):
+            await model.add_machine(constraints={'mem': 'foo'})
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_juju_error_in_results_list(event_loop):
+    '''
+    Replicate the code that caused
+    https://github.com/juju/python-libjuju/issues/67, and verify that
+    we get a JujuError instead of passing silently by the failure.
+
+    (We don't raise a JujuAPIError, because the request isn't
+    completely invalid -- it's just passing a tag that doesn't exist.)
+
+    This also verifies that we will raise a JujuError any time there
+    is an error in one of a list of results.
+
+    '''
+    from juju.errors import JujuError
+    from juju.client import client
+
+    async with base.CleanModel() as model:
+        ann_facade = client.AnnotationsFacade.from_connection(model.connection())
+
+        ann = client.EntityAnnotations(
+            entity='badtag',
+            annotations={'gui-x': '1', 'gui-y': '1'},
+        )
+        with pytest.raises(JujuError):
+            return await ann_facade.Set([ann])
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_juju_error_in_result(event_loop):
+    '''
+    Verify that we raise a JujuError when appropraite when we are
+    looking at a single result coming back.
+
+    '''
+    from juju.errors import JujuError
+    from juju.client import client
+
+    async with base.CleanModel() as model:
+        app_facade = client.ApplicationFacade.from_connection(model.connection())
+
+        with pytest.raises(JujuError):
+            return await app_facade.GetCharmURL('foo')
diff --git a/modules/libjuju/tests/integration/test_macaroon_auth.py b/modules/libjuju/tests/integration/test_macaroon_auth.py
new file mode 100644
index 0000000..9911c41
--- /dev/null
+++ b/modules/libjuju/tests/integration/test_macaroon_auth.py
@@ -0,0 +1,108 @@
+import logging
+import os
+
+import macaroonbakery.bakery as bakery
+import macaroonbakery.httpbakery as httpbakery
+import macaroonbakery.httpbakery.agent as agent
+from juju.errors import JujuAPIError
+from juju.model import Model
+
+import pytest
+
+from .. import base
+
+log = logging.getLogger(__name__)
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+@pytest.mark.xfail
+async def test_macaroon_auth(event_loop):
+    auth_info, username = agent_auth_info()
+    # Create a bakery client that can do agent authentication.
+    client = httpbakery.Client(
+        key=auth_info.key,
+        interaction_methods=[agent.AgentInteractor(auth_info)],
+    )
+
+    async with base.CleanModel(bakery_client=client) as m:
+        async with await m.get_controller() as c:
+            await c.grant_model(username, m.info.uuid, 'admin')
+        async with Model(
+            jujudata=NoAccountsJujuData(m._connector.jujudata),
+            bakery_client=client,
+        ):
+            pass
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+@pytest.mark.xfail
+async def test_macaroon_auth_with_bad_key(event_loop):
+    auth_info, username = agent_auth_info()
+    # Use a random key rather than the correct key.
+    auth_info = auth_info._replace(key=bakery.generate_key())
+    # Create a bakery client can do agent authentication.
+    client = httpbakery.Client(
+        key=auth_info.key,
+        interaction_methods=[agent.AgentInteractor(auth_info)],
+    )
+
+    async with base.CleanModel(bakery_client=client) as m:
+        async with await m.get_controller() as c:
+            await c.grant_model(username, m.info.uuid, 'admin')
+        try:
+            async with Model(
+                jujudata=NoAccountsJujuData(m._connector.jujudata),
+                bakery_client=client,
+            ):
+                pytest.fail('Should not be able to connect with invalid key')
+        except httpbakery.BakeryException:
+            # We're expecting this because we're using the
+            # wrong key.
+            pass
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_macaroon_auth_with_unauthorized_user(event_loop):
+    auth_info, username = agent_auth_info()
+    # Create a bakery client can do agent authentication.
+    client = httpbakery.Client(
+        key=auth_info.key,
+        interaction_methods=[agent.AgentInteractor(auth_info)],
+    )
+    async with base.CleanModel(bakery_client=client) as m:
+        # Note: no grant of rights to the agent user.
+        try:
+            async with Model(
+                jujudata=NoAccountsJujuData(m._connector.jujudata),
+                bakery_client=client,
+            ):
+                pytest.fail('Should not be able to connect without grant')
+        except (JujuAPIError, httpbakery.DischargeError):
+            # We're expecting this because we're using the
+            # wrong user name.
+            pass
+
+
+def agent_auth_info():
+    agent_data = os.environ.get('TEST_AGENTS')
+    if agent_data is None:
+        pytest.skip('skipping macaroon_auth because no TEST_AGENTS '
+                    'environment variable is set')
+    auth_info = agent.read_auth_info(agent_data)
+    if len(auth_info.agents) != 1:
+        raise Exception('TEST_AGENTS agent data requires exactly one agent')
+    return auth_info, auth_info.agents[0].username
+
+
+class NoAccountsJujuData:
+    def __init__(self, jujudata):
+        self.__jujudata = jujudata
+
+    def __getattr__(self, name):
+        return getattr(self.__jujudata, name)
+
+    def accounts(self):
+        return {}
diff --git a/modules/libjuju/tests/integration/test_machine.py b/modules/libjuju/tests/integration/test_machine.py
new file mode 100644
index 0000000..070208a
--- /dev/null
+++ b/modules/libjuju/tests/integration/test_machine.py
@@ -0,0 +1,65 @@
+import asyncio
+from tempfile import NamedTemporaryFile
+
+import pytest
+
+from .. import base
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_status(event_loop):
+    async with base.CleanModel() as model:
+        await model.deploy(
+            'ubuntu-0',
+            application_name='ubuntu',
+            series='trusty',
+            channel='stable',
+        )
+
+        await asyncio.wait_for(
+            model.block_until(lambda: len(model.machines)),
+            timeout=240)
+        machine = model.machines['0']
+
+        assert machine.status in ('allocating', 'pending')
+        assert machine.agent_status == 'pending'
+        assert not machine.agent_version
+
+        # there is some inconsistency in the capitalization of status_message
+        # between different providers
+        await asyncio.wait_for(
+            model.block_until(
+                lambda: (machine.status == 'running' and
+                         machine.status_message.lower() == 'running' and
+                         machine.agent_status == 'started')),
+            timeout=480)
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_scp(event_loop):
+    # ensure that asyncio.subprocess will work;
+    try:
+        asyncio.get_child_watcher().attach_loop(event_loop)
+    except RuntimeError:
+        pytest.skip('test_scp will always fail outside of MainThread')
+    async with base.CleanModel() as model:
+        await model.add_machine()
+        await asyncio.wait_for(
+            model.block_until(lambda: model.machines),
+            timeout=240)
+        machine = model.machines['0']
+        await asyncio.wait_for(
+            model.block_until(lambda: (machine.status == 'running' and
+                                       machine.agent_status == 'started')),
+            timeout=480)
+
+        with NamedTemporaryFile() as f:
+            f.write(b'testcontents')
+            f.flush()
+            await machine.scp_to(f.name, 'testfile', scp_opts='-p')
+
+        with NamedTemporaryFile() as f:
+            await machine.scp_from('testfile', f.name, scp_opts='-p')
+            assert f.read() == b'testcontents'
diff --git a/modules/libjuju/tests/integration/test_model.py b/modules/libjuju/tests/integration/test_model.py
new file mode 100644
index 0000000..58766b4
--- /dev/null
+++ b/modules/libjuju/tests/integration/test_model.py
@@ -0,0 +1,485 @@
+import asyncio
+import mock
+from concurrent.futures import ThreadPoolExecutor
+from pathlib import Path
+import paramiko
+
+from juju.client.client import ConfigValue, ApplicationFacade
+from juju.model import Model, ModelObserver
+from juju.utils import block_until, run_with_interrupt
+from juju.errors import JujuError
+
+import os
+import pylxd
+import time
+import uuid
+
+import pytest
+
+from .. import base
+
+
+MB = 1
+GB = 1024
+SSH_KEY = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCsYMJGNGG74HAJha3n2CFmWYsOOaORnJK6VqNy86pj0MIpvRXBzFzVy09uPQ66GOQhTEoJHEqE77VMui7+62AcMXT+GG7cFHcnU8XVQsGM6UirCcNyWNysfiEMoAdZScJf/GvoY87tMEszhZIUV37z8PUBx6twIqMdr31W1J0IaPa+sV6FEDadeLaNTvancDcHK1zuKsL39jzAg7+LYjKJfEfrsQP+lj/EQcjtKqlhVS5kzsJVfx8ZEd0xhW5G7N6bCdKNalS8mKCMaBXJpijNQ82AiyqCIDCRrre2To0/i7pTjRiL0U9f9mV3S4NJaQaokR050w/ZLySFf6F7joJT mathijs@Qrama-Mathijs'  # noqa
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_deploy_local_bundle_dir(event_loop):
+    tests_dir = Path(__file__).absolute().parent.parent
+    bundle_path = tests_dir / 'bundle'
+    mini_bundle_file_path = bundle_path / 'mini-bundle.yaml'
+
+    async with base.CleanModel() as model:
+        await model.deploy(str(bundle_path))
+        await model.deploy(str(mini_bundle_file_path))
+
+        wordpress = model.applications.get('wordpress')
+        mysql = model.applications.get('mysql')
+        assert wordpress and mysql
+        await block_until(lambda: (len(wordpress.units) == 1 and
+                                   len(mysql.units) == 1),
+                          timeout=60 * 4)
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_deploy_local_bundle_file(event_loop):
+    tests_dir = Path(__file__).absolute().parent.parent
+    bundle_path = tests_dir / 'bundle'
+    mini_bundle_file_path = bundle_path / 'mini-bundle.yaml'
+
+    async with base.CleanModel() as model:
+        await model.deploy(str(mini_bundle_file_path))
+
+        dummy_sink = model.applications.get('dummy-sink')
+        dummy_subordinate = model.applications.get('dummy-subordinate')
+        assert dummy_sink and dummy_subordinate
+        await block_until(lambda: (len(dummy_sink.units) == 1 and
+                                   len(dummy_subordinate.units) == 1),
+                          timeout=60 * 4)
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_deploy_invalid_bundle(event_loop):
+    tests_dir = Path(__file__).absolute().parent.parent
+    bundle_path = tests_dir / 'bundle' / 'invalid.yaml'
+    async with base.CleanModel() as model:
+        with pytest.raises(JujuError):
+            await model.deploy(str(bundle_path))
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_deploy_local_charm(event_loop):
+    from pathlib import Path
+    tests_dir = Path(__file__).absolute().parent.parent
+    charm_path = tests_dir / 'charm'
+
+    async with base.CleanModel() as model:
+        await model.deploy(str(charm_path))
+        assert 'charm' in model.applications
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_deploy_invalid_bundle(event_loop):
+    tests_dir = Path(__file__).absolute().parent.parent
+    bundle_path = tests_dir / 'bundle' / 'invalid.yaml'
+    async with base.CleanModel() as model:
+        with pytest.raises(JujuError):
+            await model.deploy(str(bundle_path))
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_deploy_local_charm(event_loop):
+    from pathlib import Path
+    tests_dir = Path(__file__).absolute().parent.parent
+    charm_path = tests_dir / 'charm'
+
+    async with base.CleanModel() as model:
+        await model.deploy(str(charm_path))
+        assert 'charm' in model.applications
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_deploy_bundle(event_loop):
+    async with base.CleanModel() as model:
+        await model.deploy('bundle/wiki-simple')
+
+        for app in ('wiki', 'mysql'):
+            assert app in model.applications
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_deploy_channels_revs(event_loop):
+    async with base.CleanModel() as model:
+        charm = 'cs:~johnsca/libjuju-test'
+        stable = await model.deploy(charm, 'a1')
+        edge = await model.deploy(charm, 'a2', channel='edge')
+        rev = await model.deploy(charm + '-2', 'a3')
+
+        assert [a.charm_url for a in (stable, edge, rev)] == [
+            'cs:~johnsca/libjuju-test-1',
+            'cs:~johnsca/libjuju-test-2',
+            'cs:~johnsca/libjuju-test-2',
+        ]
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_add_machine(event_loop):
+    from juju.machine import Machine
+
+    async with base.CleanModel() as model:
+        # add a new default machine
+        machine1 = await model.add_machine()
+
+        # add a machine with constraints, disks, and series
+        machine2 = await model.add_machine(
+            constraints={
+                'mem': 256 * MB,
+            },
+            disks=[{
+                'pool': 'rootfs',
+                'size': 10 * GB,
+                'count': 1,
+            }],
+            series='xenial',
+        )
+
+        # add a lxd container to machine2
+        machine3 = await model.add_machine(
+            'lxd:{}'.format(machine2.id))
+
+        for m in (machine1, machine2, machine3):
+            assert isinstance(m, Machine)
+
+        assert len(model.machines) == 3
+
+        await machine3.destroy(force=True)
+        await machine2.destroy(force=True)
+        res = await machine1.destroy(force=True)
+
+        assert res is None
+        assert len(model.machines) == 0
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_add_manual_machine_ssh(event_loop):
+
+    # Verify controller is localhost
+    async with base.CleanController() as controller:
+        cloud = await controller.get_cloud()
+        if cloud != "localhost":
+            pytest.skip('Skipping because test requires lxd.')
+
+    async with base.CleanModel() as model:
+        private_key_path = os.path.expanduser(
+            "~/.local/share/juju/ssh/juju_id_rsa"
+        )
+        public_key_path = os.path.expanduser(
+            "~/.local/share/juju/ssh/juju_id_rsa.pub"
+        )
+
+        # connect using the local unix socket
+        client = pylxd.Client()
+
+        test_name = "test-{}-add-manual-machine-ssh".format(
+            uuid.uuid4().hex[-4:]
+        )
+
+        # create profile w/cloud-init and juju ssh key
+        public_key = ""
+        with open(public_key_path, "r") as f:
+            public_key = f.readline()
+
+        profile = client.profiles.create(
+            test_name,
+            config={'user.user-data': '#cloud-config\n'
+                                      'ssh_authorized_keys:\n'
+                                      '- {}'.format(public_key)},
+            devices={
+                'root': {'path': '/', 'pool': 'default', 'type': 'disk'},
+                'eth0': {
+                    'nictype': 'bridged',
+                    'parent': 'lxdbr0',
+                    'type': 'nic'
+                }
+            }
+        )
+
+        # create lxc machine
+        config = {
+            'name': test_name,
+            'source': {
+                'type': 'image',
+                'alias': 'xenial',
+                'mode': 'pull',
+                'protocol': 'simplestreams',
+                'server': 'https://cloud-images.ubuntu.com/releases',
+            },
+            'profiles': [test_name],
+        }
+        container = client.containers.create(config, wait=True)
+        container.start(wait=True)
+
+        def wait_for_network(container, timeout=30):
+            """Wait for eth0 to have an ipv4 address."""
+            starttime = time.time()
+            while(time.time() < starttime + timeout):
+                time.sleep(1)
+                if 'eth0' in container.state().network:
+                    addresses = container.state().network['eth0']['addresses']
+                    if len(addresses) > 0:
+                        if addresses[0]['family'] == 'inet':
+                            return addresses[0]
+            return None
+
+        host = wait_for_network(container)
+        assert host, 'Failed to get address for machine'
+
+        # HACK: We need to give sshd a chance to bind to the interface,
+        # and pylxd's container.execute seems to be broken and fails and/or
+        # hangs trying to properly check if the service is up.
+        time.sleep(5)
+
+        for attempt in range(1, 4):
+            try:
+                # add a new manual machine
+                machine1 = await model.add_machine(spec='ssh:{}@{}:{}'.format(
+                    "ubuntu",
+                    host['address'],
+                    private_key_path,
+                ))
+            except paramiko.ssh_exception.NoValidConnectionsError:
+                if attempt == 3:
+                    raise
+                # retry the ssh connection a few times if it fails
+                time.sleep(attempt * 5)
+            else:
+                break
+
+            assert len(model.machines) == 1
+
+            res = await machine1.destroy(force=True)
+
+            assert res is None
+            assert len(model.machines) == 0
+
+        container.stop(wait=True)
+        container.delete(wait=True)
+
+        profile.delete()
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_relate(event_loop):
+    from juju.relation import Relation
+
+    async with base.CleanModel() as model:
+        await model.deploy(
+            'ubuntu',
+            application_name='ubuntu',
+            series='trusty',
+            channel='stable',
+        )
+        await model.deploy(
+            'nrpe',
+            application_name='nrpe',
+            series='trusty',
+            channel='stable',
+            # subordinates must be deployed without units
+            num_units=0,
+        )
+
+        relation_added = asyncio.Event()
+        timeout = asyncio.Event()
+
+        class TestObserver(ModelObserver):
+            async def on_relation_add(self, delta, old, new, model):
+                if set(new.key.split()) == {'nrpe:general-info',
+                                            'ubuntu:juju-info'}:
+                    relation_added.set()
+                    event_loop.call_later(2, timeout.set)
+
+        model.add_observer(TestObserver())
+
+        real_app_facade = ApplicationFacade.from_connection(model.connection())
+        mock_app_facade = mock.MagicMock()
+
+        async def mock_AddRelation(*args):
+            # force response delay from AddRelation to test race condition
+            # (see https://github.com/juju/python-libjuju/issues/191)
+            result = await real_app_facade.AddRelation(*args)
+            await relation_added.wait()
+            return result
+
+        mock_app_facade.AddRelation = mock_AddRelation
+
+        with mock.patch.object(ApplicationFacade, 'from_connection',
+                               return_value=mock_app_facade):
+            my_relation = await run_with_interrupt(model.add_relation(
+                'ubuntu',
+                'nrpe',
+            ), timeout, loop=event_loop)
+
+        assert isinstance(my_relation, Relation)
+
+
+async def _deploy_in_loop(new_loop, model_name, jujudata):
+    new_model = Model(new_loop, jujudata=jujudata)
+    await new_model.connect(model_name)
+    try:
+        await new_model.deploy('cs:xenial/ubuntu')
+        assert 'ubuntu' in new_model.applications
+    finally:
+        await new_model.disconnect()
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_explicit_loop_threaded(event_loop):
+    async with base.CleanModel() as model:
+        model_name = model.info.name
+        new_loop = asyncio.new_event_loop()
+        with ThreadPoolExecutor(1) as executor:
+            f = executor.submit(
+                new_loop.run_until_complete,
+                _deploy_in_loop(new_loop,
+                                model_name,
+                                model._connector.jujudata))
+            f.result()
+        await model._wait_for_new('application', 'ubuntu')
+        assert 'ubuntu' in model.applications
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_store_resources_charm(event_loop):
+    async with base.CleanModel() as model:
+        ghost = await model.deploy('cs:ghost-19')
+        assert 'ghost' in model.applications
+        terminal_statuses = ('active', 'error', 'blocked')
+        await model.block_until(
+            lambda: (
+                len(ghost.units) > 0 and
+                ghost.units[0].workload_status in terminal_statuses)
+        )
+        # ghost will go in to blocked (or error, for older
+        # charm revs) if the resource is missing
+        assert ghost.units[0].workload_status == 'active'
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_store_resources_bundle(event_loop):
+    async with base.CleanModel() as model:
+        bundle = str(Path(__file__).parent / 'bundle')
+        await model.deploy(bundle)
+        assert 'ghost' in model.applications
+        ghost = model.applications['ghost']
+        terminal_statuses = ('active', 'error', 'blocked')
+        await model.block_until(
+            lambda: (
+                len(ghost.units) > 0 and
+                ghost.units[0].workload_status in terminal_statuses)
+        )
+        # ghost will go in to blocked (or error, for older
+        # charm revs) if the resource is missing
+        assert ghost.units[0].workload_status == 'active'
+        resources = await ghost.get_resources()
+        assert resources['ghost-stable'].revision >= 12
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_store_resources_bundle_revs(event_loop):
+    async with base.CleanModel() as model:
+        bundle = str(Path(__file__).parent / 'bundle/bundle-resource-rev.yaml')
+        await model.deploy(bundle)
+        assert 'ghost' in model.applications
+        ghost = model.applications['ghost']
+        terminal_statuses = ('active', 'error', 'blocked')
+        await model.block_until(
+            lambda: (
+                len(ghost.units) > 0 and
+                ghost.units[0].workload_status in terminal_statuses)
+        )
+        # ghost will go in to blocked (or error, for older
+        # charm revs) if the resource is missing
+        assert ghost.units[0].workload_status == 'active'
+        resources = await ghost.get_resources()
+        assert resources['ghost-stable'].revision == 11
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_ssh_key(event_loop):
+    async with base.CleanModel() as model:
+        await model.add_ssh_key('admin', SSH_KEY)
+        result = await model.get_ssh_key(True)
+        result = result.serialize()['results'][0].serialize()['result']
+        assert SSH_KEY in result
+        await model.remove_ssh_key('admin', SSH_KEY)
+        result = await model.get_ssh_key(True)
+        result = result.serialize()['results'][0].serialize()['result']
+        assert result is None
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_get_machines(event_loop):
+    async with base.CleanModel() as model:
+        result = await model.get_machines()
+        assert isinstance(result, list)
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_watcher_reconnect(event_loop):
+    async with base.CleanModel() as model:
+        await model.connection().ws.close()
+        await block_until(model.is_connected, timeout=3)
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_config(event_loop):
+    async with base.CleanModel() as model:
+        await model.set_config({
+            'extra-info': 'booyah',
+            'test-mode': ConfigValue(value=True),
+        })
+        result = await model.get_config()
+        assert 'extra-info' in result
+        assert result['extra-info'].source == 'model'
+        assert result['extra-info'].value == 'booyah'
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_set_constraints(event_loop):
+    async with base.CleanModel() as model:
+        await model.set_constraints({'cpu-power': 1})
+        cons = await model.get_constraints()
+        assert cons['cpu_power'] == 1
+
+# @base.bootstrapped
+# @pytest.mark.asyncio
+# async def test_grant(event_loop)
+#    async with base.CleanController() as controller:
+#        await controller.add_user('test-model-grant')
+#        await controller.grant('test-model-grant', 'superuser')
+#    async with base.CleanModel() as model:
+#        await model.grant('test-model-grant', 'admin')
+#        assert model.get_user('test-model-grant')['access'] == 'admin'
+#        await model.grant('test-model-grant', 'login')
+#        assert model.get_user('test-model-grant')['access'] == 'login'
diff --git a/modules/libjuju/tests/integration/test_unit.py b/modules/libjuju/tests/integration/test_unit.py
new file mode 100644
index 0000000..bb34969
--- /dev/null
+++ b/modules/libjuju/tests/integration/test_unit.py
@@ -0,0 +1,99 @@
+import asyncio
+from tempfile import NamedTemporaryFile
+
+import pytest
+
+from .. import base
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_run(event_loop):
+    from juju.action import Action
+
+    async with base.CleanModel() as model:
+        app = await model.deploy(
+            'ubuntu-0',
+            application_name='ubuntu',
+            series='trusty',
+            channel='stable',
+        )
+
+        for unit in app.units:
+            action = await unit.run('unit-get public-address')
+            assert isinstance(action, Action)
+            assert 'Stdout' in action.results
+            break
+
+        for unit in app.units:
+            action = await unit.run('sleep 1', timeout=0.5)
+            assert isinstance(action, Action)
+            assert action.status == 'failed'
+            break
+
+        for unit in app.units:
+            action = await unit.run('sleep 0.5', timeout=2)
+            assert isinstance(action, Action)
+            assert action.status == 'completed'
+            break
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_run_action(event_loop):
+    async def run_action(unit):
+        # unit.run() returns a juju.action.Action instance
+        action = await unit.run_action('add-repo', repo='myrepo')
+        # wait for the action to complete
+        return await action.wait()
+
+    async with base.CleanModel() as model:
+        app = await model.deploy(
+            'git',
+            application_name='git',
+            series='trusty',
+            channel='stable',
+        )
+
+        for unit in app.units:
+            action = await run_action(unit)
+            assert action.results == {'dir': '/var/git/myrepo.git'}
+            out = await model.get_action_output(action.entity_id, wait=5)
+            assert out == {'dir': '/var/git/myrepo.git'}
+            status = await model.get_action_status(uuid_or_prefix=action.entity_id)
+            assert status[action.entity_id] == 'completed'
+            break
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
+async def test_scp(event_loop):
+    # ensure that asyncio.subprocess will work;
+    try:
+        asyncio.get_child_watcher().attach_loop(event_loop)
+    except RuntimeError:
+        pytest.skip('test_scp will always fail outside of MainThread')
+    async with base.CleanModel() as model:
+        app = await model.deploy('ubuntu')
+
+        await asyncio.wait_for(
+            model.block_until(lambda: app.units),
+            timeout=60)
+        unit = app.units[0]
+        await asyncio.wait_for(
+            model.block_until(lambda: unit.machine),
+            timeout=60)
+        machine = unit.machine
+        await asyncio.wait_for(
+            model.block_until(lambda: (machine.status == 'running' and
+                                       machine.agent_status == 'started')),
+            timeout=480)
+
+        with NamedTemporaryFile() as f:
+            f.write(b'testcontents')
+            f.flush()
+            await unit.scp_to(f.name, 'testfile')
+
+        with NamedTemporaryFile() as f:
+            await unit.scp_from('testfile', f.name)
+            assert f.read() == b'testcontents'
diff --git a/modules/libjuju/tests/unit/__init__.py b/modules/libjuju/tests/unit/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/modules/libjuju/tests/unit/__init__.py
diff --git a/modules/libjuju/tests/unit/test_client.py b/modules/libjuju/tests/unit/test_client.py
new file mode 100644
index 0000000..1d18bf9
--- /dev/null
+++ b/modules/libjuju/tests/unit/test_client.py
@@ -0,0 +1,26 @@
+"""
+Tests for generated client code
+
+"""
+
+import mock
+from juju.client import client
+
+
+def test_basics():
+    assert client.CLIENTS
+    for i in range(1, 5):  # Assert versions 1-4 in client dict
+        assert str(i) in client.CLIENTS
+
+
+def test_from_connection():
+    connection = mock.Mock()
+    connection.facades = {"Action": 2}
+    action_facade = client.ActionFacade.from_connection(connection)
+    assert action_facade
+
+
+def test_to_json():
+    uml = client.UserModelList([client.UserModel()])
+    assert uml.to_json() == ('{"user-models": [{"last-connection": null, '
+                             '"model": null}]}')
diff --git a/modules/libjuju/tests/unit/test_connection.py b/modules/libjuju/tests/unit/test_connection.py
new file mode 100644
index 0000000..0925d84
--- /dev/null
+++ b/modules/libjuju/tests/unit/test_connection.py
@@ -0,0 +1,65 @@
+import asyncio
+import json
+from collections import deque
+
+import mock
+from juju.client.connection import Connection
+from websockets.exceptions import ConnectionClosed
+
+import pytest
+
+from .. import base
+
+
+class WebsocketMock:
+    def __init__(self, responses):
+        super().__init__()
+        self.responses = deque(responses)
+        self.open = True
+
+    async def send(self, message):
+        pass
+
+    async def recv(self):
+        if not self.responses:
+            await asyncio.sleep(1)  # delay to give test time to finish
+            raise ConnectionClosed(0, 'ran out of responses')
+        return json.dumps(self.responses.popleft())
+
+    async def close(self):
+        self.open = False
+
+
+@pytest.mark.asyncio
+async def test_out_of_order(event_loop):
+    ws = WebsocketMock([
+        {'request-id': 1},
+        {'request-id': 3},
+        {'request-id': 2},
+    ])
+    expected_responses = [
+        {'request-id': 1},
+        {'request-id': 2},
+        {'request-id': 3},
+    ]
+    minimal_facades = [{'name': 'Pinger', 'versions': [1]}]
+    con = None
+    try:
+        with \
+                mock.patch('websockets.connect', base.AsyncMock(return_value=ws)), \
+                mock.patch(
+                    'juju.client.connection.Connection.login',
+                    base.AsyncMock(return_value={'response': {
+                        'facades': minimal_facades,
+                    }}),
+                ), \
+                mock.patch('juju.client.connection.Connection._get_ssl'), \
+                mock.patch('juju.client.connection.Connection._pinger', base.AsyncMock()):
+            con = await Connection.connect('0.1.2.3:999')
+        actual_responses = []
+        for i in range(3):
+            actual_responses.append(await con.rpc({'version': 1}))
+        assert actual_responses == expected_responses
+    finally:
+        if con:
+            await con.close()
diff --git a/modules/libjuju/tests/unit/test_constraints.py b/modules/libjuju/tests/unit/test_constraints.py
new file mode 100644
index 0000000..3c52090
--- /dev/null
+++ b/modules/libjuju/tests/unit/test_constraints.py
@@ -0,0 +1,57 @@
+#
+# Test our constraints parser
+#
+
+import unittest
+
+from juju import constraints
+
+
+class TestConstraints(unittest.TestCase):
+
+    def test_mem_regex(self):
+        m = constraints.MEM
+        self.assertTrue(m.match("10G"))
+        self.assertTrue(m.match("1G"))
+        self.assertFalse(m.match("1Gb"))
+        self.assertFalse(m.match("a1G"))
+        self.assertFalse(m.match("1000"))
+
+    def test_normalize_key(self):
+        _ = constraints.normalize_key
+
+        self.assertEqual(_("test-key"), "test_key")
+        self.assertEqual(_("test-key  "), "test_key")
+        self.assertEqual(_("  test-key"), "test_key")
+        self.assertEqual(_("TestKey"), "test_key")
+        self.assertEqual(_("testKey"), "test_key")
+
+    def test_normalize_val(self):
+        _ = constraints.normalize_value
+
+        self.assertEqual(_("10G"), 10 * 1024)
+        self.assertEqual(_("10M"), 10)
+        self.assertEqual(_("10"), 10)
+        self.assertEqual(_("foo,bar"), "foo,bar")
+
+    def test_normalize_list_val(self):
+        _ = constraints.normalize_list_value
+
+        self.assertEqual(_("foo"), ["foo"])
+        self.assertEqual(_("foo,bar"), ["foo", "bar"])
+
+    def test_parse_constraints(self):
+        _ = constraints.parse
+
+        self.assertEqual(
+            _("mem=10G"),
+            {"mem": 10 * 1024}
+        )
+
+        self.assertEqual(
+            _("mem=10G foo=bar,baz tags=tag1 spaces=space1,space2"),
+            {"mem": 10 * 1024,
+             "foo": "bar,baz",
+             "tags": ["tag1"],
+             "spaces": ["space1", "space2"]}
+        )
diff --git a/modules/libjuju/tests/unit/test_controller.py b/modules/libjuju/tests/unit/test_controller.py
new file mode 100644
index 0000000..b95b5ee
--- /dev/null
+++ b/modules/libjuju/tests/unit/test_controller.py
@@ -0,0 +1,140 @@
+import asynctest
+import mock
+from pathlib import Path
+from tempfile import NamedTemporaryFile
+
+from juju.controller import Controller
+from juju.client import client
+
+from .. import base
+
+
+class TestControllerConnect(asynctest.TestCase):
+    @asynctest.patch('juju.client.connector.Connector.connect_controller')
+    async def test_no_args(self, mock_connect_controller):
+        c = Controller()
+        await c.connect()
+        mock_connect_controller.assert_called_once_with(None)
+
+    @asynctest.patch('juju.client.connector.Connector.connect_controller')
+    async def test_with_controller_name(self, mock_connect_controller):
+        c = Controller()
+        await c.connect(controller_name='foo')
+        mock_connect_controller.assert_called_once_with('foo')
+
+    @asynctest.patch('juju.client.connector.Connector.connect')
+    async def test_with_endpoint_and_no_auth(self, mock_connect):
+        c = Controller()
+        with self.assertRaises(TypeError):
+            await c.connect(endpoint='0.1.2.3:4566')
+        self.assertEqual(mock_connect.call_count, 0)
+
+    @asynctest.patch('juju.client.connector.Connector.connect')
+    async def test_with_endpoint_and_userpass(self, mock_connect):
+        c = Controller()
+        with self.assertRaises(TypeError):
+            await c.connect(endpoint='0.1.2.3:4566', username='dummy')
+        await c.connect(endpoint='0.1.2.3:4566',
+                        username='user',
+                        password='pass')
+        mock_connect.assert_called_once_with(endpoint='0.1.2.3:4566',
+                                             username='user',
+                                             password='pass')
+
+    @asynctest.patch('juju.client.connector.Connector.connect')
+    async def test_with_endpoint_and_bakery_client(self, mock_connect):
+        c = Controller()
+        await c.connect(endpoint='0.1.2.3:4566', bakery_client='bakery')
+        mock_connect.assert_called_once_with(endpoint='0.1.2.3:4566',
+                                             bakery_client='bakery')
+
+    @asynctest.patch('juju.client.connector.Connector.connect')
+    async def test_with_endpoint_and_macaroons(self, mock_connect):
+        c = Controller()
+        await c.connect(endpoint='0.1.2.3:4566',
+                        macaroons=['macaroon'])
+        mock_connect.assert_called_with(endpoint='0.1.2.3:4566',
+                                        macaroons=['macaroon'])
+        await c.connect(endpoint='0.1.2.3:4566',
+                        bakery_client='bakery',
+                        macaroons=['macaroon'])
+        mock_connect.assert_called_with(endpoint='0.1.2.3:4566',
+                                        bakery_client='bakery',
+                                        macaroons=['macaroon'])
+
+    @asynctest.patch('juju.client.connector.Connector.connect_controller')
+    @asynctest.patch('juju.client.connector.Connector.connect')
+    async def test_with_posargs(self, mock_connect, mock_connect_controller):
+        c = Controller()
+        await c.connect('foo')
+        mock_connect_controller.assert_called_once_with('foo')
+        with self.assertRaises(TypeError):
+            await c.connect('endpoint', 'user')
+        await c.connect('endpoint', 'user', 'pass')
+        mock_connect.assert_called_once_with(endpoint='endpoint',
+                                             username='user',
+                                             password='pass')
+        await c.connect('endpoint', 'user', 'pass', 'cacert', 'bakery',
+                        'macaroons', 'loop', 'max_frame_size')
+        mock_connect.assert_called_with(endpoint='endpoint',
+                                        username='user',
+                                        password='pass',
+                                        cacert='cacert',
+                                        bakery_client='bakery',
+                                        macaroons='macaroons',
+                                        loop='loop',
+                                        max_frame_size='max_frame_size')
+
+    @asynctest.patch('juju.client.client.CloudFacade')
+    async def test_file_cred_v2(self, mock_cf):
+        with NamedTemporaryFile() as tempfile:
+            tempfile.close()
+            temppath = Path(tempfile.name)
+            temppath.write_text('cred-test')
+            cred = client.CloudCredential(auth_type='jsonfile',
+                                          attrs={'file': tempfile.name})
+            jujudata = mock.MagicMock()
+            c = Controller(jujudata=jujudata)
+            c._connector = base.AsyncMock()
+            up_creds = base.AsyncMock()
+            cloud_facade = mock_cf.from_connection()
+            cloud_facade.version = 2
+            cloud_facade.UpdateCredentials = up_creds
+            await c.add_credential(
+                name='name',
+                credential=cred,
+                cloud='cloud',
+                owner='owner',
+            )
+            assert up_creds.called
+            new_cred = up_creds.call_args[0][0][0].credential
+            assert cred.attrs['file'] == tempfile.name
+            assert new_cred.attrs['file'] == 'cred-test'
+
+    @asynctest.patch('juju.client.client.CloudFacade')
+    async def test_file_cred_v3(self, mock_cf):
+        with NamedTemporaryFile() as tempfile:
+            tempfile.close()
+            temppath = Path(tempfile.name)
+            temppath.write_text('cred-test')
+            cred = client.CloudCredential(auth_type='jsonfile',
+                                          attrs={'file': tempfile.name})
+            jujudata = mock.MagicMock()
+            c = Controller(jujudata=jujudata)
+            c._connector = base.AsyncMock()
+            up_creds = base.AsyncMock()
+            cloud_facade = mock_cf.from_connection()
+            cloud_facade.version = 3
+            cloud_facade.UpdateCredentialsCheckModels = up_creds
+            await c.add_credential(
+                name='name',
+                credential=cred,
+                cloud='cloud',
+                owner='owner',
+                force=True,
+            )
+            assert up_creds.called
+            assert up_creds.call_args[1]['force']
+            new_cred = up_creds.call_args[1]['credentials'][0].credential
+            assert cred.attrs['file'] == tempfile.name
+            assert new_cred.attrs['file'] == 'cred-test'
diff --git a/modules/libjuju/tests/unit/test_gocookies.py b/modules/libjuju/tests/unit/test_gocookies.py
new file mode 100644
index 0000000..033a0e9
--- /dev/null
+++ b/modules/libjuju/tests/unit/test_gocookies.py
@@ -0,0 +1,244 @@
+"""
+Tests for the gocookies code.
+"""
+import os
+import shutil
+import tempfile
+import unittest
+import urllib.request
+
+import pyrfc3339
+from juju.client.gocookies import GoCookieJar
+
+# cookie_content holds the JSON contents of a Go-produced
+# cookie file (reformatted so it's not all on one line but
+# otherwise unchanged).
+cookie_content = """
+[
+    {
+        "CanonicalHost": "bar.com",
+        "Creation": "2017-11-17T08:53:55.088820092Z",
+        "Domain": "bar.com",
+        "Expires": "2345-11-15T18:16:08Z",
+        "HostOnly": true,
+        "HttpOnly": false,
+        "LastAccess": "2017-11-17T08:53:55.088822562Z",
+        "Name": "bar",
+        "Path": "/",
+        "Persistent": true,
+        "Secure": false,
+        "Updated": "2017-11-17T08:53:55.088822562Z",
+        "Value": "bar-value"
+    },
+    {
+        "CanonicalHost": "x.foo.com",
+        "Creation": "2017-11-17T08:53:55.088814857Z",
+        "Domain": "x.foo.com",
+        "Expires": "2345-11-15T18:16:05Z",
+        "HostOnly": true,
+        "HttpOnly": false,
+        "LastAccess": "2017-11-17T08:53:55.088884015Z",
+        "Name": "foo",
+        "Path": "/path",
+        "Persistent": true,
+        "Secure": false,
+        "Updated": "2017-11-17T08:53:55.088814857Z",
+        "Value": "foo-path-value"
+    },
+    {
+        "CanonicalHost": "x.foo.com",
+        "Creation": "2017-11-17T08:53:55.088814857Z",
+        "Domain": "foo.com",
+        "Expires": "2345-11-15T18:16:06Z",
+        "HostOnly": false,
+        "HttpOnly": false,
+        "LastAccess": "2017-11-17T08:53:55.088919437Z",
+        "Name": "foo4",
+        "Path": "/path",
+        "Persistent": true,
+        "Secure": false,
+        "Updated": "2017-11-17T08:53:55.088814857Z",
+        "Value": "foo4-value"
+    },
+    {
+        "CanonicalHost": "x.foo.com",
+        "Creation": "2017-11-17T08:53:55.088790709Z",
+        "Domain": "x.foo.com",
+        "Expires": "2345-11-15T18:16:01Z",
+        "HostOnly": true,
+        "HttpOnly": false,
+        "LastAccess": "2017-11-17T08:53:55.088884015Z",
+        "Name": "foo",
+        "Path": "/",
+        "Persistent": true,
+        "Secure": false,
+        "Updated": "2017-11-17T08:53:55.088790709Z",
+        "Value": "foo-value"
+    },
+    {
+        "CanonicalHost": "x.foo.com",
+        "Creation": "2017-11-17T08:53:55.088790709Z",
+        "Domain": "foo.com",
+        "Expires": "2345-11-15T18:16:02Z",
+        "HostOnly": false,
+        "HttpOnly": false,
+        "LastAccess": "2017-11-17T08:53:55.088919437Z",
+        "Name": "foo1",
+        "Path": "/",
+        "Persistent": true,
+        "Secure": false,
+        "Updated": "2017-11-17T08:53:55.088790709Z",
+        "Value": "foo1-value"
+    },
+    {
+        "CanonicalHost": "x.foo.com",
+        "Creation": "2017-11-17T08:53:55.088790709Z",
+        "Domain": "x.foo.com",
+        "Expires": "2345-11-15T18:16:03Z",
+        "HostOnly": true,
+        "HttpOnly": false,
+        "LastAccess": "2017-11-17T08:53:55.088850252Z",
+        "Name": "foo2",
+        "Path": "/",
+        "Persistent": true,
+        "Secure": true,
+        "Updated": "2017-11-17T08:53:55.088790709Z",
+        "Value": "foo2-value"
+    },
+    {
+        "CanonicalHost": "x.foo.com",
+        "Creation": "2017-11-17T08:53:55.088790709Z",
+        "Domain": "foo.com",
+        "Expires": "2345-11-15T18:16:04Z",
+        "HostOnly": false,
+        "HttpOnly": false,
+        "LastAccess": "2017-11-17T08:53:55.088919437Z",
+        "Name": "foo3",
+        "Path": "/",
+        "Persistent": true,
+        "Secure": false,
+        "Updated": "2017-11-17T08:53:55.088790709Z",
+        "Value": "foo3-value"
+    }
+]
+"""
+
+# cookie_content_queries holds a set of queries
+# that were automatically generated by running
+# the queries on the above cookie_content data
+# and printing the results.
+cookie_content_queries = [
+    ('http://x.foo.com', [
+        ('foo', 'foo-value'),
+        ('foo1', 'foo1-value'),
+        ('foo3', 'foo3-value'),
+    ]),
+    ('https://x.foo.com', [
+        ('foo', 'foo-value'),
+        ('foo1', 'foo1-value'),
+        ('foo2', 'foo2-value'),
+        ('foo3', 'foo3-value'),
+    ]),
+    ('http://arble.foo.com', [
+        ('foo1', 'foo1-value'),
+        ('foo3', 'foo3-value'),
+    ]),
+    ('http://arble.com', [
+    ]),
+    ('http://x.foo.com/path/x', [
+        ('foo', 'foo-path-value'),
+        ('foo4', 'foo4-value'),
+        ('foo', 'foo-value'),
+        ('foo1', 'foo1-value'),
+        ('foo3', 'foo3-value'),
+    ]),
+    ('http://arble.foo.com/path/x', [
+        ('foo4', 'foo4-value'),
+        ('foo1', 'foo1-value'),
+        ('foo3', 'foo3-value'),
+    ]),
+    ('http://foo.com/path/x', [
+        ('foo4', 'foo4-value'),
+        ('foo1', 'foo1-value'),
+        ('foo3', 'foo3-value'),
+    ]),
+]
+
+
+class TestGoCookieJar(unittest.TestCase):
+    def setUp(self):
+        self.dir = tempfile.mkdtemp()
+
+    def tearDown(self):
+        shutil.rmtree(self.dir)
+
+    def test_readcookies(self):
+        jar = self.load_jar(cookie_content)
+        self.assert_jar_queries(jar, cookie_content_queries)
+
+    def test_roundtrip(self):
+        jar = self.load_jar(cookie_content)
+        filename2 = os.path.join(self.dir, 'cookies2')
+        jar.save(filename=filename2)
+        jar = GoCookieJar()
+        jar.load(filename=filename2)
+        self.assert_jar_queries(jar, cookie_content_queries)
+
+    def test_expiry_time(self):
+        content = '''[
+            {
+                "CanonicalHost": "bar.com",
+                "Creation": "2017-11-17T08:53:55.088820092Z",
+                "Domain": "bar.com",
+                "Expires": "2345-11-15T18:16:08Z",
+                "HostOnly": true,
+                "HttpOnly": false,
+                "LastAccess": "2017-11-17T08:53:55.088822562Z",
+                "Name": "bar",
+                "Path": "/",
+                "Persistent": true,
+                "Secure": false,
+                "Updated": "2017-11-17T08:53:55.088822562Z",
+                "Value": "bar-value"
+            }
+        ]'''
+        jar = self.load_jar(content)
+        got_expires = tuple(jar)[0].expires
+        want_expires = int(pyrfc3339.parse('2345-11-15T18:16:08Z').timestamp())
+        self.assertEqual(got_expires, want_expires)
+
+    def load_jar(self, content):
+        filename = os.path.join(self.dir, 'cookies')
+        with open(filename, 'x') as f:
+            f.write(content)
+        jar = GoCookieJar()
+        jar.load(filename=filename)
+        return jar
+
+    def assert_jar_queries(self, jar, queries):
+        '''Assert that all the given queries (see cookie_content_queries)
+        are satisfied when run on the given cookie jar.
+        :param jar CookieJar: the cookie jar to query
+        :param queries: the queries to run.
+        '''
+        for url, want_cookies in queries:
+            req = urllib.request.Request(url)
+            jar.add_cookie_header(req)
+            # We can't use SimpleCookie to find out what cookies
+            # have been presented, because SimpleCookie
+            # only allows one cookie with a given name,
+            # so we naively parse the cookies ourselves, which
+            # is OK because we know we don't have to deal
+            # with any complex cases.
+
+            cookie_header = req.get_header('Cookie')
+            got_cookies = []
+            if cookie_header is not None:
+                got_cookies = [
+                    tuple(part.split('='))
+                    for part in cookie_header.split('; ')
+                ]
+                got_cookies.sort()
+            want_cookies = list(want_cookies)
+            want_cookies.sort()
+            self.assertEqual(got_cookies, want_cookies, msg='query {}; got {}; want {}'.format(url, got_cookies, want_cookies))
diff --git a/modules/libjuju/tests/unit/test_loop.py b/modules/libjuju/tests/unit/test_loop.py
new file mode 100644
index 0000000..9043df6
--- /dev/null
+++ b/modules/libjuju/tests/unit/test_loop.py
@@ -0,0 +1,32 @@
+import asyncio
+import unittest
+
+import juju.loop
+
+
+class TestLoop(unittest.TestCase):
+    def setUp(self):
+        # new event loop for each test
+        policy = asyncio.get_event_loop_policy()
+        self.loop = policy.new_event_loop()
+        policy.set_event_loop(self.loop)
+
+    def tearDown(self):
+        self.loop.close()
+
+    def test_run(self):
+        assert asyncio.get_event_loop() == self.loop
+
+        async def _test():
+            return 'success'
+        self.assertEqual(juju.loop.run(_test()), 'success')
+
+    def test_run_interrupt(self):
+        async def _test():
+            juju.loop.run._sigint = True
+        self.assertRaises(KeyboardInterrupt, juju.loop.run, _test())
+
+    def test_run_exception(self):
+        async def _test():
+            raise ValueError()
+        self.assertRaises(ValueError, juju.loop.run, _test())
diff --git a/modules/libjuju/tests/unit/test_model.py b/modules/libjuju/tests/unit/test_model.py
new file mode 100644
index 0000000..2753d85
--- /dev/null
+++ b/modules/libjuju/tests/unit/test_model.py
@@ -0,0 +1,264 @@
+import unittest
+
+import mock
+
+import asynctest
+
+from juju.client.jujudata import FileJujuData
+from juju.model import Model
+
+
+def _make_delta(entity, type_, data=None):
+    from juju.client.client import Delta
+    from juju.delta import get_entity_delta
+
+    delta = Delta([entity, type_, data])
+    return get_entity_delta(delta)
+
+
+class TestObserver(unittest.TestCase):
+    def _make_observer(self, *args):
+        from juju.model import _Observer
+        return _Observer(*args)
+
+    def test_cares_about_id(self):
+        id_ = 'foo'
+
+        o = self._make_observer(
+            None, None, None, id_, None)
+
+        delta = _make_delta(
+            'application', 'change', dict(name=id_))
+
+        self.assertTrue(o.cares_about(delta))
+
+    def test_cares_about_type(self):
+        type_ = 'application'
+
+        o = self._make_observer(
+            None, type_, None, None, None)
+
+        delta = _make_delta(
+            type_, 'change', dict(name='foo'))
+
+        self.assertTrue(o.cares_about(delta))
+
+    def test_cares_about_action(self):
+        action = 'change'
+
+        o = self._make_observer(
+            None, None, action, None, None)
+
+        delta = _make_delta(
+            'application', action, dict(name='foo'))
+
+        self.assertTrue(o.cares_about(delta))
+
+    def test_cares_about_predicate(self):
+        def predicate(delta):
+            return delta.data.get('fizz') == 'bang'
+
+        o = self._make_observer(
+            None, None, None, None, predicate)
+
+        delta = _make_delta(
+            'application', 'change', dict(fizz='bang'))
+
+        self.assertTrue(o.cares_about(delta))
+
+
+class TestModelState(unittest.TestCase):
+    def test_apply_delta(self):
+        from juju.model import Model
+        from juju.application import Application
+
+        model = Model()
+        model._connector = mock.MagicMock()
+        delta = _make_delta('application', 'add', dict(name='foo'))
+
+        # test add
+        prev, new = model.state.apply_delta(delta)
+        self.assertEqual(
+            len(model.state.state[delta.entity][delta.get_id()]), 1)
+        self.assertIsNone(prev)
+        self.assertIsInstance(new, Application)
+
+        # test remove
+        delta.type = 'remove'
+        prev, new = model.state.apply_delta(delta)
+        # length of the entity history deque is now 3:
+        # - 1 for the first delta
+        # - 1 for the second delta
+        # - 1 for the None sentinel appended after the 'remove'
+        self.assertEqual(
+            len(model.state.state[delta.entity][delta.get_id()]), 3)
+        self.assertIsInstance(new, Application)
+        # new object is falsy because its data is None
+        self.assertFalse(new)
+        self.assertIsInstance(prev, Application)
+        self.assertTrue(prev)
+
+
+def test_get_series():
+    from juju.model import Model
+    model = Model()
+    entity = {
+        'Meta': {
+            'supported-series': {
+                'SupportedSeries': [
+                    'xenial',
+                    'trusty',
+                ],
+            },
+        },
+    }
+    assert model._get_series('cs:trusty/ubuntu', entity) == 'trusty'
+    assert model._get_series('xenial/ubuntu', entity) == 'xenial'
+    assert model._get_series('~foo/xenial/ubuntu', entity) == 'xenial'
+    assert model._get_series('~foo/ubuntu', entity) == 'xenial'
+    assert model._get_series('ubuntu', entity) == 'xenial'
+    assert model._get_series('cs:ubuntu', entity) == 'xenial'
+
+
+class TestContextManager(asynctest.TestCase):
+    @asynctest.patch('juju.model.Model.disconnect')
+    @asynctest.patch('juju.model.Model.connect')
+    async def test_normal_use(self, mock_connect, mock_disconnect):
+        from juju.model import Model
+
+        async with Model() as model:
+            self.assertTrue(isinstance(model, Model))
+
+        self.assertTrue(mock_connect.called)
+        self.assertTrue(mock_disconnect.called)
+
+    @asynctest.patch('juju.model.Model.disconnect')
+    @asynctest.patch('juju.model.Model.connect')
+    async def test_exception(self, mock_connect, mock_disconnect):
+        from juju.model import Model
+
+        class SomeException(Exception):
+            pass
+
+        with self.assertRaises(SomeException):
+            async with Model():
+                raise SomeException()
+
+        self.assertTrue(mock_connect.called)
+        self.assertTrue(mock_disconnect.called)
+
+    async def test_no_current_connection(self):
+        from juju.model import Model
+        from juju.errors import JujuConnectionError
+
+        class NoControllerJujuData(FileJujuData):
+            def current_controller(self):
+                return ""
+
+        with self.assertRaises(JujuConnectionError):
+            async with Model(jujudata=NoControllerJujuData()):
+                pass
+
+
+@asynctest.patch('juju.model.Model._after_connect')
+class TestModelConnect(asynctest.TestCase):
+    @asynctest.patch('juju.client.connector.Connector.connect_model')
+    async def test_no_args(self, mock_connect_model, _):
+        m = Model()
+        await m.connect()
+        mock_connect_model.assert_called_once_with(None)
+
+    @asynctest.patch('juju.client.connector.Connector.connect_model')
+    async def test_with_model_name(self, mock_connect_model, _):
+        m = Model()
+        await m.connect(model_name='foo')
+        mock_connect_model.assert_called_once_with('foo')
+
+    @asynctest.patch('juju.client.connector.Connector.connect_model')
+    async def test_with_endpoint_but_no_uuid(self, mock_connect_model, _):
+        m = Model()
+        with self.assertRaises(TypeError):
+            await m.connect(endpoint='0.1.2.3:4566')
+        self.assertEqual(mock_connect_model.call_count, 0)
+
+    @asynctest.patch('juju.client.connector.Connector.connect')
+    async def test_with_endpoint_and_uuid_no_auth(self, mock_connect, _):
+        m = Model()
+        with self.assertRaises(TypeError):
+            await m.connect(endpoint='0.1.2.3:4566', uuid='some-uuid')
+        self.assertEqual(mock_connect.call_count, 0)
+
+    @asynctest.patch('juju.client.connector.Connector.connect')
+    async def test_with_endpoint_and_uuid_with_userpass(self, mock_connect, _):
+        m = Model()
+        with self.assertRaises(TypeError):
+            await m.connect(endpoint='0.1.2.3:4566',
+                            uuid='some-uuid',
+                            username='user')
+        await m.connect(endpoint='0.1.2.3:4566',
+                        uuid='some-uuid',
+                        username='user',
+                        password='pass')
+        mock_connect.assert_called_once_with(endpoint='0.1.2.3:4566',
+                                             uuid='some-uuid',
+                                             username='user',
+                                             password='pass')
+
+    @asynctest.patch('juju.client.connector.Connector.connect')
+    async def test_with_endpoint_and_uuid_with_bakery(self, mock_connect, _):
+        m = Model()
+        await m.connect(endpoint='0.1.2.3:4566',
+                        uuid='some-uuid',
+                        bakery_client='bakery')
+        mock_connect.assert_called_once_with(endpoint='0.1.2.3:4566',
+                                             uuid='some-uuid',
+                                             bakery_client='bakery')
+
+    @asynctest.patch('juju.client.connector.Connector.connect')
+    async def test_with_endpoint_and_uuid_with_macaroon(self, mock_connect, _):
+        m = Model()
+        with self.assertRaises(TypeError):
+            await m.connect(endpoint='0.1.2.3:4566',
+                            uuid='some-uuid',
+                            username='user')
+        await m.connect(endpoint='0.1.2.3:4566',
+                        uuid='some-uuid',
+                        macaroons=['macaroon'])
+        mock_connect.assert_called_with(endpoint='0.1.2.3:4566',
+                                        uuid='some-uuid',
+                                        macaroons=['macaroon'])
+        await m.connect(endpoint='0.1.2.3:4566',
+                        uuid='some-uuid',
+                        bakery_client='bakery',
+                        macaroons=['macaroon'])
+        mock_connect.assert_called_with(endpoint='0.1.2.3:4566',
+                                        uuid='some-uuid',
+                                        bakery_client='bakery',
+                                        macaroons=['macaroon'])
+
+    @asynctest.patch('juju.client.connector.Connector.connect_model')
+    @asynctest.patch('juju.client.connector.Connector.connect')
+    async def test_with_posargs(self, mock_connect, mock_connect_model, _):
+        m = Model()
+        await m.connect('foo')
+        mock_connect_model.assert_called_once_with('foo')
+        with self.assertRaises(TypeError):
+            await m.connect('endpoint', 'uuid')
+        with self.assertRaises(TypeError):
+            await m.connect('endpoint', 'uuid', 'user')
+        await m.connect('endpoint', 'uuid', 'user', 'pass')
+        mock_connect.assert_called_once_with(endpoint='endpoint',
+                                             uuid='uuid',
+                                             username='user',
+                                             password='pass')
+        await m.connect('endpoint', 'uuid', 'user', 'pass', 'cacert', 'bakery',
+                        'macaroons', 'loop', 'max_frame_size')
+        mock_connect.assert_called_with(endpoint='endpoint',
+                                        uuid='uuid',
+                                        username='user',
+                                        password='pass',
+                                        cacert='cacert',
+                                        bakery_client='bakery',
+                                        macaroons='macaroons',
+                                        loop='loop',
+                                        max_frame_size='max_frame_size')
diff --git a/modules/libjuju/tests/unit/test_overrides.py b/modules/libjuju/tests/unit/test_overrides.py
new file mode 100644
index 0000000..a5835ff
--- /dev/null
+++ b/modules/libjuju/tests/unit/test_overrides.py
@@ -0,0 +1,76 @@
+from juju.client.overrides import Binary, Number  # noqa
+
+import pytest
+
+
+# test cases ported from:
+# https://github.com/juju/version/blob/master/version_test.go
+@pytest.mark.parametrize("input,expected", (
+    (None, Number(major=0, minor=0, patch=0, tag='', build=0)),
+    (Number(major=1, minor=0, patch=0), Number(major=1, minor=0, patch=0)),
+    ({'major': 1, 'minor': 0, 'patch': 0}, Number(major=1, minor=0, patch=0)),
+    ("0.0.1", Number(major=0, minor=0, patch=1)),
+    ("0.0.2", Number(major=0, minor=0, patch=2)),
+    ("0.1.0", Number(major=0, minor=1, patch=0)),
+    ("0.2.3", Number(major=0, minor=2, patch=3)),
+    ("1.0.0", Number(major=1, minor=0, patch=0)),
+    ("10.234.3456", Number(major=10, minor=234, patch=3456)),
+    ("10.234.3456.1", Number(major=10, minor=234, patch=3456, build=1)),
+    ("10.234.3456.64", Number(major=10, minor=234, patch=3456, build=64)),
+    ("10.235.3456", Number(major=10, minor=235, patch=3456)),
+    ("1.21-alpha1", Number(major=1, minor=21, patch=1, tag="alpha")),
+    ("1.21-alpha1.1", Number(major=1, minor=21, patch=1, tag="alpha",
+                             build=1)),
+    ("1.21-alpha10", Number(major=1, minor=21, patch=10, tag="alpha")),
+    ("1.21.0", Number(major=1, minor=21)),
+    ("1234567890.2.1", TypeError),
+    ("0.2..1", TypeError),
+    ("1.21.alpha1", TypeError),
+    ("1.21-alpha", TypeError),
+    ("1.21-alpha1beta", TypeError),
+    ("1.21-alpha-dev", TypeError),
+    ("1.21-alpha_dev3", TypeError),
+    ("1.21-alpha123dev3", TypeError),
+))
+def test_number(input, expected):
+    if expected is TypeError:
+        with pytest.raises(expected):
+            Number.from_json(input)
+    else:
+        result = Number.from_json(input)
+        assert result == expected
+        if isinstance(input, str):
+            assert result.to_json() == input
+
+
+# test cases ported from:
+# https://github.com/juju/version/blob/master/version_test.go
+@pytest.mark.parametrize("input,expected", (
+    (None, Binary(Number(), None, None)),
+    (Binary(Number(1), 'trusty', 'amd64'), Binary(Number(1),
+                                                  'trusty', 'amd64')),
+    ({'number': {'major': 1},
+      'series': 'trusty',
+      'arch': 'amd64'}, Binary(Number(1), 'trusty', 'amd64')),
+    ("1.2.3-trusty-amd64", Binary(Number(1, 2, 3, "", 0),
+                                  "trusty", "amd64")),
+    ("1.2.3.4-trusty-amd64", Binary(Number(1, 2, 3, "", 4),
+                                    "trusty", "amd64")),
+    ("1.2-alpha3-trusty-amd64", Binary(Number(1, 2, 3, "alpha", 0),
+                                       "trusty", "amd64")),
+    ("1.2-alpha3.4-trusty-amd64", Binary(Number(1, 2, 3, "alpha", 4),
+                                         "trusty", "amd64")),
+    ("1.2.3", TypeError),
+    ("1.2-beta1", TypeError),
+    ("1.2.3--amd64", TypeError),
+    ("1.2.3-trusty-", TypeError),
+))
+def test_binary(input, expected):
+    if expected is TypeError:
+        with pytest.raises(expected):
+            Binary.from_json(input)
+    else:
+        result = Binary.from_json(input)
+        assert result == expected
+        if isinstance(input, str):
+            assert result.to_json() == input
diff --git a/modules/libjuju/tests/unit/test_placement.py b/modules/libjuju/tests/unit/test_placement.py
new file mode 100644
index 0000000..5a933ec
--- /dev/null
+++ b/modules/libjuju/tests/unit/test_placement.py
@@ -0,0 +1,20 @@
+#
+# Test our placement helper
+#
+
+import unittest
+
+from juju import placement
+
+
+class TestPlacement(unittest.TestCase):
+
+    def test_parse_both_specified(self):
+        res = placement.parse("foo:bar")
+        self.assertEqual(res[0].scope, "foo")
+        self.assertEqual(res[0].directive, "bar")
+
+    def test_parse_machine(self):
+        res = placement.parse("22")
+        self.assertEqual(res[0].scope, "#")
+        self.assertEqual(res[0].directive, "22")
diff --git a/modules/libjuju/tests/unit/test_registration_string.py b/modules/libjuju/tests/unit/test_registration_string.py
new file mode 100644
index 0000000..f4fea44
--- /dev/null
+++ b/modules/libjuju/tests/unit/test_registration_string.py
@@ -0,0 +1,18 @@
+#
+# Test our placement helper
+#
+
+import unittest
+
+from juju.utils import generate_user_controller_access_token
+
+
+class TestRegistrationString(unittest.TestCase):
+    def test_generate_user_controller_access_token(self):
+        controller_name = "localhost-localhost"
+        endpoints = ["192.168.1.1:17070", "192.168.1.2:17070", "192.168.1.3:17070"]
+        username = "test-01234"
+        secret_key = "paNZrqOw51ONk1kTER6rkm4hdPcg5VgC/dzXYxtUZaM="
+        reg_string = generate_user_controller_access_token(username, endpoints, secret_key, controller_name)
+        assert reg_string == b"MH4TCnRlc3QtMDEyMzQwORMRMTkyLjE2OC4xLjE6MTcwNzATETE5Mi4xNjguMS4yOjE3MDcwExExOTIuMTY4" \
+                             b"LjEuMzoxNzA3MAQgpaNZrqOw51ONk1kTER6rkm4hdPcg5VgC_dzXYxtUZaMTE2xvY2FsaG9zdC1sb2NhbGhvc3QA"