Repository: fetchai/tools-pocketbook

Issue:
Unit tests + CI 

Discussion:

Patch:
diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml
new file mode 100644
index 0000000..b6c928b
--- /dev/null
+++ b/.github/workflows/pr.yaml
@@ -0,0 +1,30 @@
+name: CI
+
+on: [push, pull_request]
+
+jobs:
+  build:
+
+    runs-on: ubuntu-latest
+    strategy:
+      max-parallel: 3
+      matrix:
+        python-version: [3.5, 3.6, 3.7]
+
+    steps:
+    - uses: actions/checkout@v1
+    - name: Set up Python ${{ matrix.python-version }}
+      uses: actions/setup-python@v1
+      with:
+        python-version: ${{ matrix.python-version }}
+    - name: Install dependencies
+      run: |
+        python -m pip install --upgrade pip
+        pip install .[dev,test]
+    - name: Lint
+      run: |
+        # stop the build if there are Python syntax errors or undefined names
+        flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
+    - name: Test
+      run: |
+        pytest
diff --git a/.gitignore b/.gitignore
index ad291b0..abb17bf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,17 @@
+# IDEs
 .idea/
 .vscode/
+
+# Build / Packaging
 build/
 dist/
+*.egg-info/
 
+# Environments
+.venv/
+Pipfile
+Pipfile.lock
+
+# Python
 *.py[cgo]
-*.egg-info/
 
diff --git a/pocketbook/address_book.py b/pocketbook/address_book.py
index 8ebd4f8..f39fd4c 100644
--- a/pocketbook/address_book.py
+++ b/pocketbook/address_book.py
@@ -1,12 +1,16 @@
 import os
+
 import toml
 
+from .constants import DEFAULT_KEY_STORE_ROOT
+
 
 class AddressBook:
-    KEY_STORE_ROOT = os.path.abspath(os.path.expanduser('~/.pocketbook'))
-    ADDRESS_BOOK_PATH = os.path.join(KEY_STORE_ROOT, 'addresses.toml')
+    INDEX_FILE_NAME = 'addresses.toml'
 
-    def __init__(self):
+    def __init__(self, root=None):
+        self._root = root or DEFAULT_KEY_STORE_ROOT
+        self._address_book_path = os.path.join(self._root, self.INDEX_FILE_NAME)
         self._address_book = self._load()
 
     def add(self, name, address):
@@ -29,12 +33,11 @@ class AddressBook:
         return self._address_book[name]
 
     def _save(self):
-        with open(self.ADDRESS_BOOK_PATH, 'w') as output_file:
+        with open(self._address_book_path, 'w') as output_file:
             toml.dump(self._address_book, output_file)
 
-    @classmethod
-    def _load(cls):
-        if not os.path.exists(cls.ADDRESS_BOOK_PATH):
+    def _load(self):
+        if not os.path.exists(self._address_book_path):
             return {}
-        with open(cls.ADDRESS_BOOK_PATH, 'r') as input_file:
+        with open(self._address_book_path, 'r') as input_file:
             return toml.load(input_file)
diff --git a/pocketbook/cli.py b/pocketbook/cli.py
index 3ff1cff..c12b74c 100644
--- a/pocketbook/cli.py
+++ b/pocketbook/cli.py
@@ -1,139 +1,16 @@
 import argparse
 import sys
-import os
-import getpass
 
-from fetchai.ledger.api import LedgerApi
-from fetchai.ledger.crypto import Entity, Address
-from fetchai.ledger.crypto.entity import _strong_password as is_strong_password
-from fetchai.ledger.serialisation.transaction import encode_transaction
+from fetchai.ledger.crypto import Address
 
-from .address_book import AddressBook
-from .key_store import KeyStore
-from .table import Table
 from . import __version__
-
-
-DISCLAIMER = """
-                                == Warning ==
-
-You use this application at your own risk. Whilst Fetch.ai have made every
-effort to ensure its reliability and security, it comes with no warranty. It is
-intended for the creation and management of Fetch.ai mainnet wallets and
-transactions between them. You are responsible for the security of your own
-private keys (see ~/.pocketbook folder). Do not use this application for
-high-value operations: it is intended for utility operations on the main network.
-"""
-
-
-class NetworkUnavailableError(Exception):
-    pass
-
-
-def get_balance(api: LedgerApi, address):
-    balance = int(api.tokens.balance(address))
-    return balance / 10000000000
-
-
-def get_stake(api: LedgerApi, addresss):
-    stake = int(api.tokens.stake(addresss))
-    return stake / 10000000000
-
-
-def create_api(name: str) -> LedgerApi:
-    try:
-        return LedgerApi(network=name)
-    except:
-        pass
-
-    raise NetworkUnavailableError()
-
-
-def run_list(args):
-    address_book = AddressBook()
-    key_store = KeyStore()
-    keys = key_store.list_keys()
-
-    if len(keys) == 0:
-        print('No keys present')
-    else:
-
-        # select the columns
-        cols = ['name', 'type', 'balance', 'stake']
-        if args.verbose:
-            cols.append('address')
-
-        api = create_api(args.network)
-
-        table = Table(cols)
-        for key in keys:
-            address = key_store.lookup_address(key)
-            balance = get_balance(api, address)
-            stake = get_stake(api, address)
-
-            row_data = {
-                'name': key,
-                'type': 'key',
-                'balance': balance,
-                'stake': stake,
-                'address': str(address),
-            }
-
-            table.add_row(**row_data)
-
-        for name, address in address_book.items():
-            balance = get_balance(api, address)
-            stake = get_stake(api, address)
-
-            row_data = {
-                'name': name,
-                'type': 'addr',
-                'balance': balance,
-                'stake': stake,
-                'address': str(address),
-            }
-
-            table.add_row(**row_data)
-
-        table.display()
-
-
-def run_create(args):
-    key_store = KeyStore()
-
-    # get the name for the new key
-    while True:
-        name = input('Enter name for key: ')
-        if name in key_store.list_keys():
-            print('Key name already exists')
-            continue
-        break
-
-    # prompt the user for the password
-    while True:
-        password = getpass.getpass('Enter password for key...: ')
-        if not is_strong_password(password):
-            print('Password too simple, try again')
-            continue
-
-        confirm = getpass.getpass('Confirm password for key.: ')
-        if password != confirm:
-            print('Passwords did not match, try again')
-            continue
-
-        break
-
-    key_store.add_key(name, password, Entity())
-
-
-def run_display(args):
-    key_store = KeyStore()
-
-    entity = key_store.load_key(args.name, getpass.getpass('Enter password for key: '))
-    address = Address(entity)
-
-    print('Address....:', str(address))
-    print('Public Key.:', str(entity.public_key))
+from .commands.add import run_add
+from .commands.create import run_create
+from .commands.display import run_display
+from .commands.list import run_list
+from .commands.transfer import run_transfer
+from .disclaimer import display_disclaimer
+from .utils import NetworkUnavailableError
 
 
 def parse_commandline():
@@ -169,88 +46,10 @@ def parse_commandline():
     return parser, parser.parse_args()
 
 
-def run_add(args):
-    address_book = AddressBook()
-    address_book.add(args.name, args.address)
-
-
-def run_transfer(args):
-    address_book = AddressBook()
-    key_store = KeyStore()
-
-    # choose the destination
-    if args.destination in address_book.keys():
-        destination = address_book.lookup_address(args.destination)
-    else:
-        destination = key_store.lookup_address(args.destination)
-        if destination is None:
-            destination = Address(args.destination)
-
-    # change the amount
-    amount = args.amount * 10000000000
-
-    # check all the signers make sense
-    for signer in args.signers:
-        if signer not in key_store.list_keys():
-            raise RuntimeError('Unknown key: {}'.format(signer))
-
-    # determine the from account
-    from_address_name = None
-    if len(args.signers) == 1 and args.from_address is None:
-        from_address_name = args.signers[0]
-    elif len(args.signers) >= 1 and args.from_address is not None:
-        present = args.from_address in key_store.list_keys() or args.from_address in address_book.keys()
-        from_address_name = args.from_address
-        if not present:
-            raise RuntimeError('Unknown from address: {}'.format(args.from_address))
-    else:
-        raise RuntimeError('Unable to determine from account')
-
-    print('Network....:', args.network)
-    print('From.......:', str(from_address_name))
-    print('Signers....:', args.signers)
-    print('Destination:', str(destination))
-    print('Amount.....:', args.amount, 'FET')
-    print()
-    input('Press enter to continue')
-
-    api = create_api(args.network)
-
-    # start unsealing the private keys
-    entities = {}
-    for signer in args.signers:
-        entity = key_store.load_key(signer, getpass.getpass('Enter password for key {}: '.format(signer)))
-        entities[signer] = entity
-
-    from_address = None
-    if from_address_name in entities:
-        from_address = Address(entities[from_address_name])
-    elif from_address_name in address_book.keys():
-        from_address = Address(address_book.lookup_address(from_address_name))
-
-    # build up the basic transaction information
-    tx = api.tokens._create_skeleton_tx(len(entities.values()))
-    tx.from_address = Address(from_address)
-    tx.add_transfer(destination, amount)
-    for entity in entities.values():
-        tx.add_signer(entity)
-
-    # encode and sign the transaction
-    encoded_tx = encode_transaction(tx, list(entities.values()))
-
-    # # submit the transaction
-    print('Submitting TX...')
-    api.sync(api.tokens._post_tx_json(encoded_tx, 'transfer'))
-    print('Submitting TX...complete')
-
-
 def main():
+    display_disclaimer()
 
-    # disclaimer
-    if not os.path.join(KeyStore.KEY_STORE_ROOT):
-        print(DISCLAIMER)
-        input('Press enter to accept')
-
+    # parse the command line
     parser, args = parse_commandline()
 
     # select the command handler
@@ -278,7 +77,3 @@ def main():
 
     # close the program with the given exit code
     sys.exit(int(exit_code))
-
-
-if __name__ == '__main__':
-    main()
diff --git a/pocketbook/commands/__init__.py b/pocketbook/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pocketbook/commands/add.py b/pocketbook/commands/add.py
new file mode 100644
index 0000000..c134751
--- /dev/null
+++ b/pocketbook/commands/add.py
@@ -0,0 +1,5 @@
+def run_add(args):
+    from pocketbook.address_book import AddressBook
+
+    address_book = AddressBook()
+    address_book.add(args.name, args.address)
diff --git a/pocketbook/commands/create.py b/pocketbook/commands/create.py
new file mode 100644
index 0000000..3169967
--- /dev/null
+++ b/pocketbook/commands/create.py
@@ -0,0 +1,32 @@
+def run_create(args):
+    from getpass import getpass
+    from fetchai.ledger.crypto.entity import _strong_password as is_strong_password
+    from fetchai.ledger.crypto import Entity
+    from pocketbook.key_store import KeyStore
+
+    key_store = KeyStore()
+    existing_keys = set(key_store.list_keys())
+
+    # get the name for the new key
+    while True:
+        name = input('Enter name for key: ')
+        if name in existing_keys:
+            print('Key name already exists')
+            continue
+        break
+
+    # prompt the user for the password
+    while True:
+        password = getpass('Enter password for key...: ')
+        if not is_strong_password(password):
+            print('Password too simple, try again')
+            continue
+
+        confirm = getpass('Confirm password for key.: ')
+        if password != confirm:
+            print('Passwords did not match, try again')
+            continue
+
+        break
+
+    key_store.add_key(name, password, Entity())
diff --git a/pocketbook/commands/display.py b/pocketbook/commands/display.py
new file mode 100644
index 0000000..0748b9e
--- /dev/null
+++ b/pocketbook/commands/display.py
@@ -0,0 +1,13 @@
+
+def run_display(args):
+    from getpass import getpass
+    from fetchai.ledger.crypto import Address
+    from pocketbook.key_store import KeyStore
+
+    key_store = KeyStore()
+
+    entity = key_store.load_key(args.name, getpass('Enter password for key: '))
+    address = Address(entity)
+
+    print('Address....:', str(address))
+    print('Public Key.:', str(entity.public_key))
diff --git a/pocketbook/commands/list.py b/pocketbook/commands/list.py
new file mode 100644
index 0000000..7fb258c
--- /dev/null
+++ b/pocketbook/commands/list.py
@@ -0,0 +1,54 @@
+
+
+def run_list(args):
+    from pocketbook.address_book import AddressBook
+    from pocketbook.key_store import KeyStore
+    from pocketbook.table import Table
+    from pocketbook.utils import create_api, get_balance, get_stake
+
+    address_book = AddressBook()
+    key_store = KeyStore()
+    keys = key_store.list_keys()
+
+    if len(keys) == 0:
+        print('No keys present')
+    else:
+
+        # select the columns
+        cols = ['name', 'type', 'balance', 'stake']
+        if args.verbose:
+            cols.append('address')
+
+        api = create_api(args.network)
+
+        table = Table(cols)
+        for key in keys:
+            address = key_store.lookup_address(key)
+            balance = get_balance(api, address)
+            stake = get_stake(api, address)
+
+            row_data = {
+                'name': key,
+                'type': 'key',
+                'balance': balance,
+                'stake': stake,
+                'address': str(address),
+            }
+
+            table.add_row(**row_data)
+
+        for name, address in address_book.items():
+            balance = get_balance(api, address)
+            stake = get_stake(api, address)
+
+            row_data = {
+                'name': name,
+                'type': 'addr',
+                'balance': balance,
+                'stake': stake,
+                'address': str(address),
+            }
+
+            table.add_row(**row_data)
+
+        table.display()
diff --git a/pocketbook/commands/transfer.py b/pocketbook/commands/transfer.py
new file mode 100644
index 0000000..c09689d
--- /dev/null
+++ b/pocketbook/commands/transfer.py
@@ -0,0 +1,79 @@
+
+
+def run_transfer(args):
+    from getpass import getpass
+
+    from fetchai.ledger.crypto import Address
+    from fetchai.ledger.serialisation.transaction import encode_transaction
+
+    from pocketbook.address_book import AddressBook
+    from pocketbook.key_store import KeyStore
+    from pocketbook.utils import create_api
+
+    address_book = AddressBook()
+    key_store = KeyStore()
+
+    # choose the destination
+    if args.destination in address_book.keys():
+        destination = address_book.lookup_address(args.destination)
+    else:
+        destination = key_store.lookup_address(args.destination)
+        if destination is None:
+            destination = Address(args.destination)
+
+    # change the amount
+    amount = args.amount * 10000000000
+
+    # check all the signers make sense
+    for signer in args.signers:
+        if signer not in key_store.list_keys():
+            raise RuntimeError('Unknown key: {}'.format(signer))
+
+    # determine the from account
+    from_address_name = None
+    if len(args.signers) == 1 and args.from_address is None:
+        from_address_name = args.signers[0]
+    elif len(args.signers) >= 1 and args.from_address is not None:
+        present = args.from_address in key_store.list_keys() or args.from_address in address_book.keys()
+        from_address_name = args.from_address
+        if not present:
+            raise RuntimeError('Unknown from address: {}'.format(args.from_address))
+    else:
+        raise RuntimeError('Unable to determine from account')
+
+    print('Network....:', args.network)
+    print('From.......:', str(from_address_name))
+    print('Signers....:', args.signers)
+    print('Destination:', str(destination))
+    print('Amount.....:', args.amount, 'FET')
+    print()
+    input('Press enter to continue')
+
+    api = create_api(args.network)
+
+    # start unsealing the private keys
+    entities = {}
+    for signer in args.signers:
+        entity = key_store.load_key(signer, getpass('Enter password for key {}: '.format(signer)))
+        entities[signer] = entity
+
+    from_address = None
+    if from_address_name in entities:
+        from_address = Address(entities[from_address_name])
+    elif from_address_name in address_book.keys():
+        from_address = Address(address_book.lookup_address(from_address_name))
+
+    # build up the basic transaction information
+    tx = api.tokens._create_skeleton_tx(len(entities.values()))
+    tx.from_address = Address(from_address)
+    tx.add_transfer(destination, amount)
+    for entity in entities.values():
+        tx.add_signer(entity)
+
+    # encode and sign the transaction
+    encoded_tx = encode_transaction(tx, list(entities.values()))
+
+    # # submit the transaction
+    print('Submitting TX...')
+    api.sync(api.tokens._post_tx_json(encoded_tx, 'transfer'))
+    print('Submitting TX...complete')
diff --git a/pocketbook/constants.py b/pocketbook/constants.py
new file mode 100644
index 0000000..81aca58
--- /dev/null
+++ b/pocketbook/constants.py
@@ -0,0 +1,3 @@
+import os
+
+DEFAULT_KEY_STORE_ROOT = os.path.abspath(os.path.expanduser('~/.pocketbook'))
diff --git a/pocketbook/disclaimer.py b/pocketbook/disclaimer.py
new file mode 100644
index 0000000..9a7dfe0
--- /dev/null
+++ b/pocketbook/disclaimer.py
@@ -0,0 +1,21 @@
+import os
+
+from .constants import DEFAULT_KEY_STORE_ROOT
+
+DISCLAIMER = """
+                                == Warning ==
+
+You use this application at your own risk. Whilst Fetch.ai have made every
+effort to ensure its reliability and security, it comes with no warranty. It is
+intended for the creation and management of Fetch.ai mainnet wallets and
+transactions between them. You are responsible for the security of your own
+private keys (see ~/.pocketbook folder). Do not use this application for
+high-value operations: it is intended for utility operations on the main network.
+"""
+
+
+def display_disclaimer(root=None):
+    key_store_root = root or DEFAULT_KEY_STORE_ROOT
+    if not os.path.isdir(key_store_root):
+        print(DISCLAIMER)
+        input('Press enter to accept')
diff --git a/pocketbook/key_store.py b/pocketbook/key_store.py
index 71e02d0..7f8b942 100644
--- a/pocketbook/key_store.py
+++ b/pocketbook/key_store.py
@@ -1,9 +1,9 @@
 import os
-import fnmatch
-
-from fetchai.ledger.crypto import Entity, Address, Identity
 
 import toml
+from fetchai.ledger.crypto import Entity, Address
+
+from .constants import DEFAULT_KEY_STORE_ROOT
 
 
 class KeyStoreError(Exception):
@@ -26,14 +26,16 @@ class UnableToDecodeKeyError(KeyStoreError):
 
 
 class KeyStore:
-    KEY_STORE_ROOT = os.path.abspath(os.path.expanduser('~/.pocketbook'))
-    KEY_STORE_INDEX = os.path.join(KEY_STORE_ROOT, 'index.toml')
+    INDEX_FILE_NAME = 'index.toml'
+
+    def __init__(self, root=None):
+        self._root = root or DEFAULT_KEY_STORE_ROOT
+        self._index_path = os.path.join(self._root, self.INDEX_FILE_NAME)
 
-    def __init__(self):
-        os.makedirs(self.KEY_STORE_ROOT, exist_ok=True)
+        os.makedirs(self._root, exist_ok=True)
 
-        if os.path.exists(self.KEY_STORE_INDEX):
-            with open(self.KEY_STORE_INDEX, 'r') as index_file:
+        if os.path.exists(self._index_path):
+            with open(self._index_path, 'r') as index_file:
                 self._index = toml.load(index_file)
         else:
             self._index = {'key': []}
@@ -86,7 +88,7 @@ class KeyStore:
         self._flush_index()
 
     def _format_key_path(self, name: str):
-        return os.path.join(self.KEY_STORE_ROOT, '{}.key'.format(name))
+        return os.path.join(self._root, '{}.key'.format(name))
 
     def _lookup_meta_data(self, name: str):
         for item in self._index['key']:
@@ -94,5 +96,5 @@ class KeyStore:
                 return item
 
     def _flush_index(self):
-        with open(self.KEY_STORE_INDEX, 'w') as index_file:
+        with open(self._index_path, 'w') as index_file:
             toml.dump(self._index, index_file)
diff --git a/pocketbook/utils.py b/pocketbook/utils.py
new file mode 100644
index 0000000..7298c64
--- /dev/null
+++ b/pocketbook/utils.py
@@ -0,0 +1,25 @@
+
+
+class NetworkUnavailableError(Exception):
+    pass
+
+
+def get_balance(api, address):
+    balance = int(api.tokens.balance(address))
+    return balance / 10000000000
+
+
+def get_stake(api, addresss):
+    stake = int(api.tokens.stake(addresss))
+    return stake / 10000000000
+
+
+def create_api(name: str):
+    from fetchai.ledger.api import LedgerApi
+
+    try:
+        return LedgerApi(network=name)
+    except:
+        pass
+
+    raise NetworkUnavailableError()
diff --git a/setup.py b/setup.py
index 1b45fb2..58b31b9 100644
--- a/setup.py
+++ b/setup.py
@@ -16,7 +16,7 @@ setup(
     ],
     extras_require={
         'dev': ['check-manifest'],
-        'test': ['coverage', 'pytest'],
+        'test': ['coverage', 'pytest', 'flake8'],
     },
     entry_points={
         'console_scripts': [


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/commands/__init__.py b/tests/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/commands/test_add.py b/tests/commands/test_add.py
new file mode 100644
index 0000000..dcb0931
--- /dev/null
+++ b/tests/commands/test_add.py
@@ -0,0 +1,18 @@
+import unittest
+from unittest.mock import patch, Mock
+
+
+class AddCommandTests(unittest.TestCase):
+
+    @patch('pocketbook.address_book.AddressBook')
+    def test_add(self, address_book):
+        from pocketbook.commands.add import run_add
+
+        args = Mock()
+        args.name = 'foo'
+        args.address = 'foo-address'
+
+        run_add(args)
+
+        instance = address_book.return_value
+        instance.add.assert_called_once_with('foo', 'foo-address')
diff --git a/tests/commands/test_create.py b/tests/commands/test_create.py
new file mode 100644
index 0000000..34c1b7d
--- /dev/null
+++ b/tests/commands/test_create.py
@@ -0,0 +1,79 @@
+import unittest
+from unittest.mock import patch
+from io import StringIO
+
+from tests.utils import SUPER_SECURE_PASSWORD
+
+from fetchai.ledger.crypto import Entity
+from pocketbook.key_store import KeyStore
+
+
+class CreateCommandTests(unittest.TestCase):
+
+    @patch('builtins.input', side_effect=['foo-bar'])
+    @patch('getpass.getpass', side_effect=[SUPER_SECURE_PASSWORD, SUPER_SECURE_PASSWORD])
+    @patch('fetchai.ledger.crypto.Entity', spec=Entity)
+    @patch('pocketbook.key_store.KeyStore', spec=KeyStore)
+    def test_simple_flow(self, MockKeyStore, MockEntity, *args):
+        key_store = MockKeyStore()
+        key_store.list_keys.return_value = []
+        entity = MockEntity()
+
+        from pocketbook.commands.create import run_create
+        run_create(None)
+
+        key_store.add_key.assert_called_once_with('foo-bar', SUPER_SECURE_PASSWORD, entity)
+
+    @patch('builtins.input', side_effect=['foo-bar'])
+    @patch('getpass.getpass', side_effect=[SUPER_SECURE_PASSWORD, '', SUPER_SECURE_PASSWORD, SUPER_SECURE_PASSWORD])
+    @patch('sys.stdout', new_callable=StringIO)
+    @patch('fetchai.ledger.crypto.Entity', spec=Entity)
+    @patch('pocketbook.key_store.KeyStore', spec=KeyStore)
+    def test_mismatching_passwords(self, MockKeyStore, MockEntity, output, *args):
+        key_store = MockKeyStore()
+        key_store.list_keys.return_value = []
+        entity = MockEntity()
+
+        from pocketbook.commands.create import run_create
+        run_create(None)
+
+        key_store.add_key.assert_called_once_with('foo-bar', SUPER_SECURE_PASSWORD, entity)
+
+        self.assertIn('Passwords did not match, try again', output.getvalue())
+
+    @patch('builtins.input', side_effect=['foo-bar'])
+    @patch('getpass.getpass', side_effect=['weak', SUPER_SECURE_PASSWORD, SUPER_SECURE_PASSWORD])
+    @patch('sys.stdout', new_callable=StringIO)
+    @patch('fetchai.ledger.crypto.Entity', spec=Entity)
+    @patch('pocketbook.key_store.KeyStore', spec=KeyStore)
+    def test_weak_password(self, MockKeyStore, MockEntity, output, *args):
+        key_store = MockKeyStore()
+        key_store.list_keys.return_value = []
+        entity = MockEntity()
+
+        from pocketbook.commands.create import run_create
+        run_create(None)
+
+        key_store.add_key.assert_called_once_with('foo-bar', SUPER_SECURE_PASSWORD, entity)
+
+        self.assertIn('Password too simple, try again', output.getvalue())
+
+    @patch('builtins.input', side_effect=['foo-bar', 'foo-baz'])
+    @patch('getpass.getpass', side_effect=[SUPER_SECURE_PASSWORD, SUPER_SECURE_PASSWORD])
+    @patch('sys.stdout', new_callable=StringIO)
+    @patch('fetchai.ledger.crypto.Entity', spec=Entity)
+    @patch('pocketbook.key_store.KeyStore', spec=KeyStore)
+    def test_re_prompt_for_duplicate_key(self, MockKeyStore, MockEntity, output, *args):
+        key_store = MockKeyStore()
+        key_store.list_keys.return_value = ['foo-bar']
+        entity = MockEntity()
+
+        from pocketbook.commands.create import run_create
+        run_create(None)
+
+        key_store.add_key.assert_called_once_with('foo-baz', SUPER_SECURE_PASSWORD, entity)
+
+        self.assertIn('Key name already exists', output.getvalue())
+
+
+
diff --git a/tests/commands/test_display.py b/tests/commands/test_display.py
new file mode 100644
index 0000000..7f58e0d
--- /dev/null
+++ b/tests/commands/test_display.py
@@ -0,0 +1,33 @@
+import unittest
+from unittest.mock import patch, Mock
+from io import StringIO
+
+from pocketbook.key_store import KeyStore
+
+from tests.utils import SUPER_SECURE_PASSWORD
+
+from fetchai.ledger.crypto import Entity, Address
+
+class DisplayCommandTests(unittest.TestCase):
+
+    @patch('getpass.getpass', return_value=SUPER_SECURE_PASSWORD)
+    @patch('sys.stdout', new_callable=StringIO)
+    @patch('pocketbook.key_store.KeyStore', spec=KeyStore)
+    def test_empty_list(self, MockKeyStore, output, *args):
+        entity = Entity()
+        address = Address(entity)
+
+        key_store = MockKeyStore()
+        key_store.load_key.return_value = entity
+
+        args = Mock()
+        args.name = 'foo-bar'
+
+        # run the command
+        from pocketbook.commands.display import run_display
+        run_display(args)
+
+        key_store.load_key.assert_called_once_with('foo-bar', SUPER_SECURE_PASSWORD)
+
+        self.assertIn(entity.public_key, output.getvalue())
+        self.assertIn(str(address), output.getvalue())
\ No newline at end of file
diff --git a/tests/commands/test_list.py b/tests/commands/test_list.py
new file mode 100644
index 0000000..caa2eb9
--- /dev/null
+++ b/tests/commands/test_list.py
@@ -0,0 +1,119 @@
+import unittest
+from io import StringIO
+from unittest.mock import patch, Mock, call
+
+from pocketbook.address_book import AddressBook
+from pocketbook.key_store import KeyStore
+from pocketbook.table import Table
+from pocketbook.utils import create_api, get_balance, get_stake
+from tests.utils import SAMPLE_ADDRESS
+
+
+class ListCommandTests(unittest.TestCase):
+
+    @patch('sys.stdout', new_callable=StringIO)
+    @patch('pocketbook.address_book.AddressBook', spec=AddressBook)
+    @patch('pocketbook.key_store.KeyStore', spec=KeyStore)
+    def test_empty_list(self, MockKeyStore, MockAddressBook, output, *args):
+        key_store = MockKeyStore()
+        key_store.list_keys.return_value = []
+
+        # run the command
+        from pocketbook.commands.list import run_list
+        run_list(None)
+
+        self.assertIn('No keys present', output.getvalue())
+
+    @patch('pocketbook.utils.get_stake', spec=get_stake)
+    @patch('pocketbook.utils.get_balance', spec=get_balance)
+    @patch('pocketbook.utils.create_api', spec=create_api)
+    @patch('pocketbook.table.Table', spec=Table)
+    @patch('pocketbook.address_book.AddressBook', spec=AddressBook)
+    @patch('pocketbook.key_store.KeyStore', spec=KeyStore)
+    def test_normal_display(self, MockKeyStore, MockAddressBook, MockTable, mock_create_api, mock_get_balance,
+                            mock_get_stake, *args):
+        key_store = MockKeyStore()
+        key_store.list_keys.return_value = ['sample']
+        key_store.lookup_address.return_value = SAMPLE_ADDRESS
+
+        address_book = MockAddressBook()
+        address_book.items.return_value = [('other', 'another-address')]
+
+        mock_get_balance.side_effect = [10, 5]
+        mock_get_stake.side_effect = [5, 10]
+
+        api = mock_create_api()
+        mock_create_api.reset_mock()
+
+        args = Mock()
+        args.verbose = False
+        args.network = 'bar-net'
+
+        table = MockTable()
+        MockTable.reset_mock()
+
+        # run the command
+        from pocketbook.commands.list import run_list
+        run_list(args)
+
+        MockTable.assert_called_once_with(['name', 'type', 'balance', 'stake'])
+        mock_create_api.assert_called_once_with('bar-net')
+        key_store.lookup_address.assert_called_once_with('sample')
+
+        self.assertEqual(mock_get_balance.call_args_list, [call(api, SAMPLE_ADDRESS), call(api, 'another-address')])
+        self.assertEqual(mock_get_stake.call_args_list, [call(api, SAMPLE_ADDRESS), call(api, 'another-address')])
+
+        # check that we call the correct number of calls
+        expected_row_calls = [
+            call(name='sample', type='key', balance=10, stake=5, address=SAMPLE_ADDRESS),
+            call(name='other', type='addr', balance=5, stake=10, address='another-address'),
+        ]
+        self.assertEqual(table.add_row.call_args_list, expected_row_calls)
+        table.display.assert_called_once_with()
+
+    @patch('pocketbook.utils.get_stake', spec=get_stake)
+    @patch('pocketbook.utils.get_balance', spec=get_balance)
+    @patch('pocketbook.utils.create_api', spec=create_api)
+    @patch('pocketbook.table.Table', spec=Table)
+    @patch('pocketbook.address_book.AddressBook', spec=AddressBook)
+    @patch('pocketbook.key_store.KeyStore', spec=KeyStore)
+    def test_verbose_display(self, MockKeyStore, MockAddressBook, MockTable, mock_create_api, mock_get_balance,
+                             mock_get_stake, *args):
+        key_store = MockKeyStore()
+        key_store.list_keys.return_value = ['sample']
+        key_store.lookup_address.return_value = SAMPLE_ADDRESS
+
+        address_book = MockAddressBook()
+        address_book.items.return_value = [('other', 'another-address')]
+
+        mock_get_balance.side_effect = [10, 5]
+        mock_get_stake.side_effect = [5, 10]
+
+        api = mock_create_api()
+        mock_create_api.reset_mock()
+
+        args = Mock()
+        args.verbose = True
+        args.network = 'bar-net'
+
+        table = MockTable()
+        MockTable.reset_mock()
+
+        # run the command
+        from pocketbook.commands.list import run_list
+        run_list(args)
+
+        MockTable.assert_called_once_with(['name', 'type', 'balance', 'stake', 'address'])
+        mock_create_api.assert_called_once_with('bar-net')
+        key_store.lookup_address.assert_called_once_with('sample')
+
+        self.assertEqual(mock_get_balance.call_args_list, [call(api, SAMPLE_ADDRESS), call(api, 'another-address')])
+        self.assertEqual(mock_get_stake.call_args_list, [call(api, SAMPLE_ADDRESS), call(api, 'another-address')])
+
+        # check that we call the correct number of calls
+        expected_row_calls = [
+            call(name='sample', type='key', balance=10, stake=5, address=SAMPLE_ADDRESS),
+            call(name='other', type='addr', balance=5, stake=10, address='another-address'),
+        ]
+        self.assertEqual(table.add_row.call_args_list, expected_row_calls)
+        table.display.assert_called_once_with()
diff --git a/tests/commands/test_transfer.py b/tests/commands/test_transfer.py
new file mode 100644
index 0000000..311952a
--- /dev/null
+++ b/tests/commands/test_transfer.py
@@ -0,0 +1,308 @@
+import unittest
+from unittest.mock import patch, Mock, MagicMock
+
+from pocketbook.address_book import AddressBook
+from pocketbook.key_store import KeyStore
+from pocketbook.utils import create_api
+
+from fetchai.ledger.crypto import Entity, Address
+from fetchai.ledger.serialisation.transaction import encode_transaction
+
+
+class Person:
+    def __init__(self, name):
+        self.name = str(name)
+        self.entity = Entity()
+        self.address = Address(self.entity)
+
+
+class TransferCommandUnitTests(unittest.TestCase):
+
+    @patch('getpass.getpass', side_effect=['weak-password'])
+    @patch('builtins.input', return_value='')
+    @patch('fetchai.ledger.serialisation.transaction.encode_transaction', spec=encode_transaction)
+    @patch('pocketbook.utils.create_api', spec=create_api)
+    @patch('pocketbook.address_book.AddressBook', spec=AddressBook)
+    @patch('pocketbook.key_store.KeyStore', spec=KeyStore)
+    def test_transfer_to_addr_dest(self, MockKeyStore, MockAddressBook, mock_create_api, mock_encode_transaction, *args):
+        person1 = Person('Jane')
+        person2 = Person('Clare')
+
+        key_store = MockKeyStore()
+        key_store.list_keys.return_value = [person1.name]
+        key_store.load_key.return_value = person1.entity
+
+        address_book = MockAddressBook()
+        address_book.keys.return_value = [person2.name]
+        address_book.lookup_address.return_value = person2.address
+
+        api = MagicMock()
+        mock_create_api.return_value = api
+
+        tx = MagicMock()
+        api.tokens._create_skeleton_tx.return_value = tx
+        api.tokens._post_tx_json.return_value = '0xTransactionHexId'
+
+        encoded_tx = MagicMock()
+        mock_encode_transaction.return_value = encoded_tx
+
+        args = Mock()
+        args.destination = person2.name
+        args.amount = 2
+        args.signers = [person1.name]
+        args.from_address = None
+        args.network = 'super-duper-net'
+
+        from pocketbook.commands.transfer import run_transfer
+        run_transfer(args)
+
+        mock_create_api.assert_called_once_with('super-duper-net')
+        key_store.load_key.assert_called_once_with(person1.name, 'weak-password')
+        api.tokens._create_skeleton_tx.assert_called_once_with(1)  # single signature
+        tx.add_transfer.assert_called_once_with(person2.address, 20000000000)
+        mock_encode_transaction.assert_called_once_with(tx, [person1.entity])
+        api.tokens._post_tx_json.assert_called_once_with(encoded_tx, 'transfer')
+        api.sync.assert_called_once_with('0xTransactionHexId')
+
+    @patch('getpass.getpass', side_effect=['weak-password'])
+    @patch('builtins.input', return_value='')
+    @patch('fetchai.ledger.serialisation.transaction.encode_transaction', spec=encode_transaction)
+    @patch('pocketbook.utils.create_api', spec=create_api)
+    @patch('pocketbook.address_book.AddressBook', spec=AddressBook)
+    @patch('pocketbook.key_store.KeyStore', spec=KeyStore)
+    def test_transfer_to_key_dest(self, MockKeyStore, MockAddressBook, mock_create_api, mock_encode_transaction, *args):
+        person1 = Person('Jane')
+        person2 = Person('Clare')
+
+        key_store = MockKeyStore()
+        key_store.list_keys.return_value = [person1.name, person2.name]
+        key_store.load_key.side_effect = [person1.entity, person2.entity]
+        key_store.lookup_address.return_value = person2.address
+
+        address_book = MockAddressBook()
+        address_book.keys.return_value = []
+
+        api = MagicMock()
+        mock_create_api.return_value = api
+
+        tx = MagicMock()
+        api.tokens._create_skeleton_tx.return_value = tx
+        api.tokens._post_tx_json.return_value = '0xTransactionHexId'
+
+        encoded_tx = MagicMock()
+        mock_encode_transaction.return_value = encoded_tx
+
+        args = Mock()
+        args.destination = person2.name
+        args.amount = 2
+        args.signers = [person1.name]
+        args.from_address = None
+        args.network = 'super-duper-net'
+
+        from pocketbook.commands.transfer import run_transfer
+        run_transfer(args)
+
+        # mock_create_api.assert_called_once_with('super-duper-net')
+        # key_store.load_key.assert_called_once_with(person1.name, 'weak-password')
+        # api.tokens._create_skeleton_tx.assert_called_once_with(1)  # single signature
+        # tx.add_transfer.assert_called_once_with(person2.address, 20000000000)
+        # mock_encode_transaction.assert_called_once_with(tx, [person1.entity])
+        # api.tokens._post_tx_json.assert_called_once_with(encoded_tx, 'transfer')
+        # api.sync.assert_called_once_with('0xTransactionHexId')
+
+    @patch('getpass.getpass', side_effect=['weak-password'])
+    @patch('builtins.input', return_value='')
+    @patch('fetchai.ledger.serialisation.transaction.encode_transaction', spec=encode_transaction)
+    @patch('pocketbook.utils.create_api', spec=create_api)
+    @patch('pocketbook.address_book.AddressBook', spec=AddressBook)
+    @patch('pocketbook.key_store.KeyStore', spec=KeyStore)
+    def test_transfer_to_new_dest(self, MockKeyStore, MockAddressBook, mock_create_api, mock_encode_transaction, *args):
+        person1 = Person('Jane')
+        person2 = Person('Clare')
+
+        key_store = MockKeyStore()
+        key_store.list_keys.return_value = [person1.name, person2.name]
+        key_store.load_key.side_effect = [person1.entity]
+        key_store.lookup_address.return_value = None
+
+        address_book = MockAddressBook()
+        address_book.keys.return_value = []
+
+        api = MagicMock()
+        mock_create_api.return_value = api
+
+        tx = MagicMock()
+        api.tokens._create_skeleton_tx.return_value = tx
+        api.tokens._post_tx_json.return_value = '0xTransactionHexId'
+
+        encoded_tx = MagicMock()
+        mock_encode_transaction.return_value = encoded_tx
+
+        args = Mock()
+        args.destination = str(person2.address)
+        args.amount = 2
+        args.signers = [person1.name]
+        args.from_address = None
+        args.network = 'super-duper-net'
+
+        from pocketbook.commands.transfer import run_transfer
+        run_transfer(args)
+
+        mock_create_api.assert_called_once_with('super-duper-net')
+        key_store.load_key.assert_called_once_with(person1.name, 'weak-password')
+        api.tokens._create_skeleton_tx.assert_called_once_with(1)  # single signature
+        tx.add_transfer.assert_called_once_with(person2.address, 20000000000)
+        mock_encode_transaction.assert_called_once_with(tx, [person1.entity])
+        api.tokens._post_tx_json.assert_called_once_with(encoded_tx, 'transfer')
+        api.sync.assert_called_once_with('0xTransactionHexId')
+
+    @patch('getpass.getpass', side_effect=['weak-password'])
+    @patch('builtins.input', return_value='')
+    @patch('fetchai.ledger.serialisation.transaction.encode_transaction', spec=encode_transaction)
+    @patch('pocketbook.utils.create_api', spec=create_api)
+    @patch('pocketbook.address_book.AddressBook', spec=AddressBook)
+    @patch('pocketbook.key_store.KeyStore', spec=KeyStore)
+    def test_multisig_transfer(self, MockKeyStore, MockAddressBook, mock_create_api, mock_encode_transaction, *args):
+        multisig = Person('MultiSig')
+        person1 = Person('Jane')
+        person2 = Person('Clare')
+
+        key_store = MockKeyStore()
+        key_store.list_keys.return_value = [person1.name, person2.name]
+        key_store.load_key.side_effect = [person1.entity]
+        key_store.lookup_address.return_value = person2.address
+
+        address_book = MockAddressBook()
+        address_book.keys.return_value = [multisig.name]
+        address_book.lookup_address.return_value = multisig.address
+
+        api = MagicMock()
+        mock_create_api.return_value = api
+
+        tx = MagicMock()
+        api.tokens._create_skeleton_tx.return_value = tx
+        api.tokens._post_tx_json.return_value = '0xTransactionHexId'
+
+        encoded_tx = MagicMock()
+        mock_encode_transaction.return_value = encoded_tx
+
+        args = Mock()
+        args.destination = person2.name
+        args.amount = 2
+        args.signers = [person1.name]
+        args.from_address = multisig.name
+        args.network = 'super-duper-net'
+
+        from pocketbook.commands.transfer import run_transfer
+        run_transfer(args)
+
+        mock_create_api.assert_called_once_with('super-duper-net')
+        address_book.lookup_address.assert_called_once_with(multisig.name)
+        key_store.lookup_address.assert_called_once_with(person2.name)
+        key_store.load_key.assert_called_once_with(person1.name, 'weak-password')
+        api.tokens._create_skeleton_tx.assert_called_once_with(1)  # single signature
+        tx.add_transfer.assert_called_once_with(person2.address, 20000000000)
+        mock_encode_transaction.assert_called_once_with(tx, [person1.entity])
+        api.tokens._post_tx_json.assert_called_once_with(encoded_tx, 'transfer')
+        api.sync.assert_called_once_with('0xTransactionHexId')
+
+
+    @patch('getpass.getpass', side_effect=['weak-password'])
+    @patch('builtins.input', return_value='')
+    @patch('fetchai.ledger.serialisation.transaction.encode_transaction', spec=encode_transaction)
+    @patch('pocketbook.utils.create_api', spec=create_api)
+    @patch('pocketbook.address_book.AddressBook', spec=AddressBook)
+    @patch('pocketbook.key_store.KeyStore', spec=KeyStore)
+    def test_error_when_signer_not_present(self, MockKeyStore, MockAddressBook, mock_create_api, mock_encode_transaction, *args):
+        person1 = Person('Jane')
+        person2 = Person('Clare')
+
+        key_store = MockKeyStore()
+        key_store.list_keys.return_value = []
+        key_store.load_key.side_effect = [person1.entity]
+        key_store.lookup_address.return_value = person2.address
+
+        address_book = MockAddressBook()
+        address_book.keys.return_value = [person1.name, person2.name]
+        address_book.lookup_address.return_value = person2.address
+
+        args = Mock()
+        args.destination = person2.name
+        args.amount = 2
+        args.signers = [person1.name]
+        args.from_address = person2.name
+        args.network = 'super-duper-net'
+
+        with self.assertRaises(RuntimeError):
+            from pocketbook.commands.transfer import run_transfer
+            run_transfer(args)
+
+        # mock_create_api.assert_called_once_with('super-duper-net')
+        address_book.lookup_address.assert_called_once_with(person2.name)
+
+
+    @patch('getpass.getpass', side_effect=['weak-password'])
+    @patch('builtins.input', return_value='')
+    @patch('fetchai.ledger.serialisation.transaction.encode_transaction', spec=encode_transaction)
+    @patch('pocketbook.utils.create_api', spec=create_api)
+    @patch('pocketbook.address_book.AddressBook', spec=AddressBook)
+    @patch('pocketbook.key_store.KeyStore', spec=KeyStore)
+    def test_error_when_from_field_is_invalid(self, MockKeyStore, MockAddressBook, mock_create_api, mock_encode_transaction, *args):
+        person1 = Person('Jane')
+        person2 = Person('Clare')
+
+        key_store = MockKeyStore()
+        key_store.list_keys.return_value = [person1.name]
+        key_store.load_key.side_effect = [person1.entity]
+        key_store.lookup_address.return_value = person2.address
+
+        address_book = MockAddressBook()
+        address_book.keys.return_value = [person2.name]
+        address_book.lookup_address.return_value = person2.address
+
+        args = Mock()
+        args.destination = person2.name
+        args.amount = 2
+        args.signers = [person1.name]
+        args.from_address = 'some-one-missing'
+        args.network = 'super-duper-net'
+
+        with self.assertRaises(RuntimeError):
+            from pocketbook.commands.transfer import run_transfer
+            run_transfer(args)
+
+        # mock_create_api.assert_called_once_with('super-duper-net')
+        address_book.lookup_address.assert_called_once_with(person2.name)
+
+    @patch('getpass.getpass', side_effect=['weak-password'])
+    @patch('builtins.input', return_value='')
+    @patch('fetchai.ledger.serialisation.transaction.encode_transaction', spec=encode_transaction)
+    @patch('pocketbook.utils.create_api', spec=create_api)
+    @patch('pocketbook.address_book.AddressBook', spec=AddressBook)
+    @patch('pocketbook.key_store.KeyStore', spec=KeyStore)
+    def test_error_case_with_bad_args(self, MockKeyStore, MockAddressBook, mock_create_api, mock_encode_transaction, *args):
+        person1 = Person('Jane')
+        person2 = Person('Clare')
+
+        key_store = MockKeyStore()
+        key_store.list_keys.return_value = [person1.name]
+        key_store.load_key.side_effect = [person1.entity]
+        key_store.lookup_address.return_value = person2.address
+
+        address_book = MockAddressBook()
+        address_book.keys.return_value = [person2.name]
+        address_book.lookup_address.return_value = person2.address
+
+        args = Mock()
+        args.destination = person2.name
+        args.amount = 2
+        args.signers = []
+        args.from_address = 'some-one-missing'
+        args.network = 'super-duper-net'
+
+        with self.assertRaises(RuntimeError):
+            from pocketbook.commands.transfer import run_transfer
+            run_transfer(args)
+
+        # mock_create_api.assert_called_once_with('super-duper-net')
+        address_book.lookup_address.assert_called_once_with(person2.name)
\ No newline at end of file
diff --git a/tests/test_address_book.py b/tests/test_address_book.py
new file mode 100644
index 0000000..10e616f
--- /dev/null
+++ b/tests/test_address_book.py
@@ -0,0 +1,79 @@
+import os
+import unittest
+
+import toml
+
+from pocketbook.address_book import AddressBook
+from .utils import TemporaryPocketBookRoot, SAMPLE_ADDRESS
+
+
+class AddressBookTests(unittest.TestCase):
+
+    def assertAddressIsPresentOnDisk(self, name: str, address: str, ctx: TemporaryPocketBookRoot):
+        address_index_path = os.path.join(ctx.root, AddressBook.INDEX_FILE_NAME)
+        self.assertTrue(os.path.isfile(address_index_path))
+
+        with open(address_index_path, 'r') as address_index_file:
+            address_index = toml.load(address_index_file)
+
+            self.assertIn(name, address_index)
+            self.assertEqual(address_index[name], address)
+
+    def test_no_writing_on_empty_folder(self):
+        with TemporaryPocketBookRoot() as ctx:
+            address_book = AddressBook(root=ctx.root)
+            self.assertEqual(len(os.listdir(ctx.root)), 0)
+
+    def test_simple_write(self):
+        with TemporaryPocketBookRoot() as ctx:
+            # add the sample address
+            address_book = AddressBook(root=ctx.root)
+            address_book.add('sample', SAMPLE_ADDRESS)
+
+            self.assertAddressIsPresentOnDisk('sample', SAMPLE_ADDRESS, ctx)
+
+    def test_check_keys_exists(self):
+        with TemporaryPocketBookRoot() as ctx:
+            address_book = AddressBook(root=ctx.root)
+            address_book.add('sample', SAMPLE_ADDRESS)
+
+            self.assertIn('sample', address_book.keys())
+            self.assertNotIn('foo', address_book.keys())
+
+    def test_get_items(self):
+        with TemporaryPocketBookRoot() as ctx:
+            address_book = AddressBook(root=ctx.root)
+            address_book.add('sample', SAMPLE_ADDRESS)
+
+            address_book_items = list(address_book.items())
+            self.assertEqual(len(address_book_items), 1)
+            self.assertEqual(address_book_items[0], ('sample', SAMPLE_ADDRESS))
+
+    def test_lookup_of_address(self):
+        with TemporaryPocketBookRoot() as ctx:
+            address_book = AddressBook(root=ctx.root)
+            address_book.add('sample', SAMPLE_ADDRESS)
+
+            self.assertEqual(address_book.lookup_address('sample'), SAMPLE_ADDRESS)
+
+    def test_invalid_lookup_of_address(self):
+        with TemporaryPocketBookRoot() as ctx:
+            address_book = AddressBook(root=ctx.root)
+            with self.assertRaises(RuntimeError):
+                address_book.lookup_address('foo-bar')
+
+    def test_throw_on_duplicate_add(self):
+        with TemporaryPocketBookRoot() as ctx:
+            address_book = AddressBook(root=ctx.root)
+            address_book.add('sample', SAMPLE_ADDRESS)
+
+            with self.assertRaises(RuntimeError):
+                address_book.add('sample', SAMPLE_ADDRESS)
+
+    def test_load_of_addresses(self):
+        with TemporaryPocketBookRoot() as ctx:
+            address_book1 = AddressBook(root=ctx.root)
+            address_book1.add('sample', SAMPLE_ADDRESS)
+
+            address_book2 = AddressBook(root=ctx.root)
+            self.assertEqual(set(address_book1.items()), set(address_book2.items()))
diff --git a/tests/test_disclaimer.py b/tests/test_disclaimer.py
new file mode 100644
index 0000000..4eb4955
--- /dev/null
+++ b/tests/test_disclaimer.py
@@ -0,0 +1,27 @@
+import unittest
+import shutil
+import unittest
+from io import StringIO
+from unittest.mock import patch
+
+from pocketbook.disclaimer import display_disclaimer, DISCLAIMER
+from .utils import TemporaryPocketBookRoot
+
+
+class DisclaimerTests(unittest.TestCase):
+
+    def test_display_of_disclaimer(self):
+        with TemporaryPocketBookRoot() as ctx:
+            shutil.rmtree(ctx.root)  # remove the root - emulate initial startup
+
+            with patch('builtins.input', return_value='\n'):
+                with patch('sys.stdout', new_callable=StringIO) as mocked_print:
+                    display_disclaimer(ctx.root)
+                    self.assertIn(DISCLAIMER, mocked_print.getvalue())
+
+    def test_no_display_of_disclaimer(self):
+        with TemporaryPocketBookRoot() as ctx:
+            with patch('builtins.input', return_value='\n'):
+                with patch('sys.stdout', new_callable=StringIO) as mocked_print:
+                    display_disclaimer(ctx.root)
+                    self.assertEqual('', mocked_print.getvalue())
diff --git a/tests/test_key_store.py b/tests/test_key_store.py
new file mode 100644
index 0000000..a3b035e
--- /dev/null
+++ b/tests/test_key_store.py
@@ -0,0 +1,129 @@
+import os
+import unittest
+
+import toml
+from fetchai.ledger.crypto import Entity, Address
+
+from pocketbook.key_store import KeyStore, DuplicateKeyNameError, KeyNotFoundError, UnableToDecodeKeyError
+from .utils import TemporaryPocketBookRoot, SUPER_SECURE_PASSWORD
+
+
+
+class AddressBookTests(unittest.TestCase):
+    def assertFlushedToDisk(self, name: str, password: str, entity: Entity, ctx: TemporaryPocketBookRoot):
+        self.assertTrue(os.path.isdir(ctx.root))
+
+        # check that the index record has been created correctly
+        index_path = os.path.join(ctx.root, KeyStore.INDEX_FILE_NAME)
+        self.assertTrue(os.path.isfile(index_path))
+        with open(index_path, 'r') as index_file:
+            index = toml.load(index_path)
+
+            # build up the key listings
+            key_addresses = {key['name']: key['address'] for key in index.get('key', [])}
+
+            # ensure the key is reference in the index
+            self.assertIn(name, key_addresses.keys())
+
+            # also ensure the computed address is also correct
+            expected_address = Address(entity)
+            self.assertEqual(key_addresses[name], str(expected_address))
+
+        # check the contents of the key
+        key_path = os.path.join(ctx.root, '{}.key'.format(name))
+        self.assertTrue(os.path.isfile(key_path))
+        with open(key_path, 'r') as key_file:
+            recovered = Entity.load(key_file, password)
+            self.assertEqual(recovered.private_key, entity.private_key)
+
+    def test_init(self):
+        with TemporaryPocketBookRoot() as ctx:
+            key_store = KeyStore(root=ctx.root)
+            self.assertEqual(len(key_store.list_keys()), 0)
+
+    def test_add_key(self):
+        with TemporaryPocketBookRoot() as ctx:
+            key_store = KeyStore(root=ctx.root)
+
+            # create an entity and add it to the store
+            entity = Entity()
+            key_store.add_key('sample', SUPER_SECURE_PASSWORD, entity)
+
+            self.assertEqual(len(key_store.list_keys()), 1)
+
+    def test_added_key_is_flushed_to_disk(self):
+        with TemporaryPocketBookRoot() as ctx:
+            key_store = KeyStore(root=ctx.root)
+
+            # create an entity and add it to the store
+            entity = Entity()
+            key_store.add_key('sample', SUPER_SECURE_PASSWORD, entity)
+
+            # check the files on have been set correctly
+            self.assertFlushedToDisk('sample', SUPER_SECURE_PASSWORD, entity, ctx)
+
+    def test_address_lookup(self):
+        with TemporaryPocketBookRoot() as ctx:
+            entity = Entity()
+            key_store = KeyStore(root=ctx.root)
+            key_store.add_key('sample', SUPER_SECURE_PASSWORD, entity)
+
+            expected_address = Address(entity)
+            recovered_address = key_store.lookup_address('sample')
+
+            self.assertEqual(str(expected_address), str(recovered_address))
+
+    def test_duplicate_adds(self):
+        with TemporaryPocketBookRoot() as ctx:
+            entity = Entity()
+            key_store = KeyStore(root=ctx.root)
+            key_store.add_key('sample', SUPER_SECURE_PASSWORD, entity)
+
+            with self.assertRaises(DuplicateKeyNameError):
+                key_store.add_key('sample', SUPER_SECURE_PASSWORD, entity)
+
+    def test_loading_of_multiple_stores(self):
+        with TemporaryPocketBookRoot() as ctx:
+            entity = Entity()
+            key_store1 = KeyStore(root=ctx.root)
+            key_store1.add_key('sample', SUPER_SECURE_PASSWORD, entity)
+
+            key_store2 = KeyStore(root=ctx.root)
+            self.assertEqual(set(key_store2.list_keys()), set(key_store1.list_keys()))
+
+    def test_loading_saved_key(self):
+        with TemporaryPocketBookRoot() as ctx:
+            entity = Entity()
+            key_store = KeyStore(root=ctx.root)
+            key_store.add_key('sample', SUPER_SECURE_PASSWORD, entity)
+
+            recovered = key_store.load_key('sample', SUPER_SECURE_PASSWORD)
+            self.assertEqual(recovered.private_key, entity.private_key)
+
+    def test_loading_invalid_key(self):
+        with TemporaryPocketBookRoot() as ctx:
+            key_store = KeyStore(root=ctx.root)
+
+            with self.assertRaises(KeyNotFoundError):
+                key_store.load_key('foo-bar', SUPER_SECURE_PASSWORD)
+
+    def test_unable_to_load_saved_key(self):
+        with TemporaryPocketBookRoot() as ctx:
+            entity = Entity()
+            key_store = KeyStore(root=ctx.root)
+            key_store.add_key('sample', SUPER_SECURE_PASSWORD, entity)
+
+            with self.assertRaises(UnableToDecodeKeyError):
+                key_store.load_key('sample', '!' + SUPER_SECURE_PASSWORD)
+
+    def test_duplicate_key_error_message(self):
+        error = DuplicateKeyNameError()
+        self.assertEqual(str(error), 'Duplicate key name found')
+
+    def test_key_not_found_error_message(self):
+        error = KeyNotFoundError()
+        self.assertEqual(str(error), 'Unable to find key in store')
+
+    def test_unable_to_decode_key_error_message(self):
+        error = UnableToDecodeKeyError()
+        self.assertEqual(str(error), 'Unable to decode key from key store')
\ No newline at end of file
diff --git a/tests/test_table.py b/tests/test_table.py
new file mode 100644
index 0000000..ade333f
--- /dev/null
+++ b/tests/test_table.py
@@ -0,0 +1,39 @@
+import unittest
+from io import StringIO
+from unittest.mock import patch
+
+from pocketbook.table import Table
+
+
+class TableTests(unittest.TestCase):
+
+    @patch('sys.stdout', new_callable=StringIO)
+    def test_empty(self, mock_print):
+        table = Table(['foo'])
+        table.display()
+
+        self.assertEqual(mock_print.getvalue(), '\x1b[38;5;255mFoo \x1b[0m\n')
+
+    @patch('sys.stdout', new_callable=StringIO)
+    def test_columns(self, mock_print):
+        table = Table(['foo', 'bar'])
+        table.add_row(foo=1, bar=2)
+        table.display()
+
+        self.assertEqual(mock_print.getvalue(),
+                         '\x1b[38;5;255mFoo Bar \x1b[0m\n\x1b[38;5;253m\x1b[48;5;238m1   2   \x1b[0m\n')
+
+    @patch('sys.stdout', new_callable=StringIO)
+    def test_multiple_rows(self, mock_print):
+        table = Table(['foo'])
+        table.add_row(foo=1)
+        table.add_row(foo=2)
+        table.add_row(foo=3)
+        table.display()
+
+        self.assertEqual(mock_print.getvalue(),
+                         '\x1b[38;5;255mFoo \x1b[0m\n\x1b[38;5;253m\x1b[48;5;238m1   \x1b[0m\n\x1b[38;5;251m\x1b[48;5;235m2   \x1b[0m\n\x1b[38;5;253m\x1b[48;5;238m3   \x1b[0m\n')
+
+    def test_invalid_padding_case(self):
+        truncated = Table._pad_value('LongWords', 5)
+        self.assertEqual(truncated, 'LongW')
\ No newline at end of file
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 0000000..a0eee66
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,45 @@
+import unittest
+from unittest.mock import MagicMock, patch
+
+from pocketbook.utils import get_balance, get_stake, NetworkUnavailableError
+
+class UtilsTests(unittest.TestCase):
+
+    def test_get_balance(self):
+        api = MagicMock()
+        api.tokens.balance.return_value = 100000000000
+
+        balance = get_balance(api, 'some address')
+        self.assertEqual(balance, 10)
+
+        api.tokens.balance.assert_called_once_with('some address')
+
+
+    def test_get_stake(self):
+        api = MagicMock()
+        api.tokens.stake.return_value = 50000000000
+
+        stake = get_stake(api, 'some address')
+        self.assertEqual(stake, 5)
+
+        api.tokens.stake.assert_called_once_with('some address')
+
+    @patch('fetchai.ledger.api.LedgerApi')
+    def test_create_api(self, MockLedgerApi):
+
+        # normal use
+        from pocketbook.utils import create_api
+        create_api('super-duper-net')
+
+        MockLedgerApi.assert_called_once_with(network='super-duper-net')
+        MockLedgerApi.reset_mock()
+
+    @patch('fetchai.ledger.api.LedgerApi', side_effect=[RuntimeError('Bad Error')])
+    def test_error_on_create_api(self, MockLedgerApi):
+
+        # internal error case
+        from pocketbook.utils import create_api
+        with self.assertRaises(NetworkUnavailableError):
+            create_api('super-duper-net')
+
+        MockLedgerApi.assert_called_once_with(network='super-duper-net')
\ No newline at end of file
diff --git a/tests/utils.py b/tests/utils.py
new file mode 100644
index 0000000..b68d512
--- /dev/null
+++ b/tests/utils.py
@@ -0,0 +1,23 @@
+import os
+import shutil
+import tempfile
+
+SAMPLE_ADDRESS = '2w9uqXLFvCve9TjfoRb2nuztuTx54E1S3AjuB9hWCMMGX5kp4W'
+SUPER_SECURE_PASSWORD = 'Fetch!Ai-ToTh3M00n!'
+
+
+class TemporaryPocketBookRoot:
+    def __init__(self):
+        self._root = None
+
+    @property
+    def root(self):
+        return self._root
+
+    def __enter__(self):
+        self._root = tempfile.mkdtemp(prefix='pocketbook_', suffix='_root')
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        if os.path.isdir(self._root):
+            shutil.rmtree(self._root)