Repository: pydicom/pynetdicom
Pull request #476 (2020-04-06 01:34:51)

[MRG] Add CLI interface for apps
#### Tasks
 - [x] Unit tests added that reproduce issue or prove feature is working
 - [x] Fix or feature added
 - [x] Documentation and examples updated (if relevant)
 - [x] Unit tests passing and coverage at 100% after adding fix/feature
 - [x] Apps updated and tested (if relevant)



Patch:
diff --git a/docs/apps/echoscp.rst b/docs/apps/echoscp.rst
index 434ca717a..de3a8b154 100644
--- a/docs/apps/echoscp.rst
+++ b/docs/apps/echoscp.rst
@@ -1,30 +1,35 @@
 =======
 echoscp
 =======
-    ``echoscp.py [options] port``
+
+.. code-block:: text
+
+    $ python -m pynetdicom echoscp [options] port
 
 Description
 ===========
 The ``echoscp`` application implements a Service Class Provider (SCP) for the
-*Verification SOP Class* (UID 1.2.840.10008.1.1). It establishes an
-Association with peer Application Entities (AEs) and listens for
+*Verification SOP Class* (*SOP Class UID* ``UID 1.2.840.10008.1.1``). It
+establishes an Association with peer Application Entities (AEs) and listens for
 DICOM C-ECHO-RQ messages to which it responds with a DICOM C-ECHO-RSP
 message. The application can be used to verify basic DICOM connectivity.
 
-The following example shows what happens when it is started and receives
+Usage
+=====
+
+The following example shows what happens when it's started and receives
 a C-ECHO request from a peer:
 
 .. code-block:: text
 
-   user@host: python echoscp.py 11112
+   $ python -m pynetdicom echoscp 11112
 
 
-More information is available when a connection is received while running with
-the ``-v`` option:
+More information is available when running with the ``-v`` option:
 
 .. code-block:: text
 
-    user@host: python echoscp.py 11112 -v
+    $ python -m pynetdicom echoscp 11112 -v
     I: Accepting Association
     I: Received Echo Request (MsgID 1)
     I: Association Released
@@ -33,7 +38,7 @@ When a peer AE attempts to send non C-ECHO message:
 
 .. code-block:: text
 
-    user@host: python echoscp.py 192.168.2.1 11112 -v
+    $ python -m pynetdicom echoscp 11112 -v
     I: Accepting Association
     I: Association Aborted
 
@@ -42,7 +47,7 @@ running with the ``-d`` option:
 
 .. code-block:: text
 
-    user@host: python echoscp.py 11112 -d
+    $ python -m pynetdicom echoscp 11112 -d
     D: echoscp.py v0.7.0
     D:
     D: Request Parameters:
diff --git a/docs/apps/echoscu.rst b/docs/apps/echoscu.rst
index 593f601bf..41006da20 100644
--- a/docs/apps/echoscu.rst
+++ b/docs/apps/echoscu.rst
@@ -1,7 +1,10 @@
 =======
 echoscu
 =======
-    ``echoscu.py [options] addr port``
+
+.. code-block:: text
+
+    $ python -m pynetdicom echoscu [options] addr port
 
 Description
 ===========
@@ -11,49 +14,48 @@ association with a peer Application Entity (AE), sends a DICOM
 :dcm:`C-ECHO-RQ<part07/sect_9.3.5.html#sect_9.3.5.1>` message and waits for a
 response. The application can be used to verify basic DICOM connectivity.
 
+Usage
+=====
+
 The following example shows what happens when it's succesfully run on
-an SCP at IP 127.0.0.1 and listen port 11112 that supports the *Verification
-Service*:
+an SCP at IP ``127.0.0.1`` and listen port ``11112`` that supports the
+*Verification Service*:
 
 .. code-block:: text
 
-    user@host: python echoscu.py 127.0.0.1 11112
-    user@host:
+    $ python -m pynetdicom echoscu 127.0.0.1 11112
 
 When attempting to send a C-ECHO request to an SCP that doesn't support the
 *Verification Service*:
 
 .. code-block:: text
 
-    user@host: python echoscu.py 127.0.0.1 11112
+    $ python -m pynetdicom echoscu 127.0.0.1 11112
     E: No accepted presentation contexts
-    user@host:
 
 When the association request is rejected by the SCP (in this case because the
 called AE title wasn't recognised):
 
 .. code-block:: text
 
-    user@host: python echoscu.py 127.0.0.1 11112
+    $ python -m pynetdicom echoscu 127.0.0.1 11112
     E: Association Rejected
     E: Result: Rejected Permanent, Source: Service User
     E: Reason: Called AE title not recognised
-    user@host:
 
 When attempting to associate with a non-DICOM peer:
 
 .. code-block:: text
 
-    user@host: python echoscu.py 127.0.0.1 11112
+    $ python -m pynetdicom echoscu 127.0.0.1 11112
     E: Association request failed: unable to connect to remote
     E: TCP Initialisation Error: Connection refused
-    user@host:
 
 More information is available with the ``-d`` flag:
 
 .. code-block:: text
 
-    user@host: python echoscu.py 127.0.0.1 11112 -d
+    $ python -m pynetdicom echoscu 127.0.0.1 11112 -d
     D: echoscu.py v0.7.0
     D:
     I: Requesting Association
@@ -66,7 +68,6 @@ More information is available with the ``-d`` flag:
     D: pydicom.read_dataset() TransferSyntax="Little Endian Implicit"
     I: Received Echo Response (Status: Success)
     I: Releasing Association
-    user@host:
 
 Parameters
 ==========
diff --git a/docs/apps/findscu.rst b/docs/apps/findscu.rst
index 3c8fafe36..4a2130d8c 100644
--- a/docs/apps/findscu.rst
+++ b/docs/apps/findscu.rst
@@ -1,7 +1,10 @@
 =======
 findscu
 =======
-    ``findscu.py [options] addr port (-k keyword and/or -f file-in)``
+
+.. code-block:: text
+
+    $ python -m pynetdicom findscu [options] addr port (-k keyword and/or -f file-in)
 
 Description
 ===========
@@ -12,13 +15,16 @@ port ``port`` and once established, sends a query to be matched against the
 SCP's managed SOP Instances. The SCP then responds with the matching query
 keys.
 
+Usage
+=====
+
 The following example shows what happens when it is succesfully run on
-an SCP at IP 127.0.0.1 and listen port 11112 that supports the *QR Find
+an SCP at IP ``127.0.0.1`` and listen port ``11112`` that supports the *QR Find
 Service*:
 
 .. code-block:: text
 
-    user@host: python findscu.py 127.0.0.1 11112 -k QueryRetrieveLevel=PATIENT -k PatientName=
+    $ python -m pynetdicom findscu 127.0.0.1 11112 -k QueryRetrieveLevel=PATIENT -k PatientName=
     I: Requesting Association
     I: Association Accepted
     I: Sending Find Request: MsgID 1
@@ -36,7 +42,6 @@ Service*:
     I:
     I: Find SCP Result: 0x0000 (Success)
     I: Releasing Association
-    user@host:
 
 Parameters
 ==========
@@ -78,7 +83,7 @@ Network Options
 Query Information Model Options
 -------------------------------
 ``-P    --patient``
-            use patient root information model
+            use patient root information model (default)
 ``-S    --study``
             use study root information model
 ``-O    --psonly``
@@ -92,7 +97,7 @@ Query Options
             add or override a query element using either an element tag as
             (group,element) or the element's keyword (such as PatientName).
             See the *keyword pathing* section for more information.
-``-f path to [f]ile``
+``-f path to [f]ile (str)``
             use a DICOM file as the query dataset, if used with ``-k``
             then the elements will be added to or overwrite those
             present in the file
diff --git a/docs/apps/getscu.rst b/docs/apps/getscu.rst
index 5c3a1e983..18a1b91b3 100644
--- a/docs/apps/getscu.rst
+++ b/docs/apps/getscu.rst
@@ -1,7 +1,10 @@
 =======
 getscu
 =======
-    ``getscu.py [options] addr port (-k keyword and/or -f file-in)``
+
+.. code-block:: text
+
+    $ python -m pynetdicom getscu [options] addr port (-k keyword and/or -f file-in)
 
 Description
 ===========
@@ -12,13 +15,16 @@ port ``port`` and once established, sends a query to be matched against the
 SCP's managed SOP Instances. The SCP then responds by sending a copy of the
 matching SOP Instances to the Get SCU (i.e. it acts as a Store SCP).
 
+Usage
+=====
+
 The following example shows what happens when it is succesfully run on
-an SCP at IP 127.0.0.1 and listen port 11112 that supports the *QR Get
+an SCP at IP ``127.0.0.1`` and listen port ``11112`` that supports the *QR Get
 Service*:
 
 .. code-block:: text
 
-    user@host: python getscu.py 127.0.0.1 11112 -k QueryRetrieveLevel=PATIENT -k PatientName=
+    $ python -m pynetdicom getscu 127.0.0.1 11112 -k QueryRetrieveLevel=PATIENT -k PatientName=
     I: Requesting Association
     I: Association Accepted
     I: Sending Get Request: MsgID 1
@@ -34,7 +40,6 @@ Service*:
     I: Get SCP Result: 0x0000 (Success)
     I: Sub-Operations Remaining: 0, Completed: 1, Failed: 0, Warning: 0
     I: Releasing Association
-    user@host:
 
 Parameters
 ==========
@@ -76,7 +81,7 @@ Network Options
 Query Information Model Options
 -------------------------------
 ``-P    --patient``
-            use patient root information model
+            use patient root information model (default)
 ``-S    --study``
             use study root information model
 ``-O    --psonly``
@@ -88,7 +93,7 @@ Query Options
             add or override a query element using either an element tag as
             (group,element) or the element's keyword (such as PatientName).
             See the *keyword pathing* section for more information.
-``-f path to [f]ile``
+``-f path to [f]ile (str)``
             use a DICOM file as the query dataset, if used with ``-k``
             then the elements will be added to or overwrite those
             present in the file
diff --git a/docs/apps/index.rst b/docs/apps/index.rst
index ae2de17dd..0f41dbb80 100644
--- a/docs/apps/index.rst
+++ b/docs/apps/index.rst
@@ -8,10 +8,10 @@ Applications
 .. toctree::
    :maxdepth: 1
 
-   echoscu
    echoscp
+   echoscu
    findscu
    getscu
    movescu
-   storescu
    storescp
+   storescu
diff --git a/docs/apps/movescu.rst b/docs/apps/movescu.rst
index 00720b830..5dc865fa7 100644
--- a/docs/apps/movescu.rst
+++ b/docs/apps/movescu.rst
@@ -1,7 +1,10 @@
 =======
 movescu
 =======
-    ``movescu.py [options] addr port (-k keyword and/or -f file-in)``
+
+.. code-block:: text
+
+    $ python -m pynetdicom movescu [options] addr port (-k keyword and/or -f file-in)
 
 Description
 ===========
@@ -12,13 +15,16 @@ port ``port`` and once established, sends a query to be matched against the
 SCP's managed SOP Instances. The SCP then responds by sending a copy of the
 matching SOP Instances to the Store SCP specified using the Move AE title.
 
+Usage
+=====
+
 The following example shows what happens when it is succesfully run on
-an SCP at IP 127.0.0.1 and listen port 11112 that supports the *QR Move
+an SCP at IP ``127.0.0.1`` and listen port ``11112`` that supports the *QR Move
 Service* with the default Move AE title ``STORESCP``:
 
 .. code-block:: text
 
-    user@host: python movescu.py 127.0.0.1 11112 -k QueryRetrieveLevel=PATIENT -k PatientName=
+    $ python -m pynetdicom movescu 127.0.0.1 11112 -k QueryRetrieveLevel=PATIENT -k PatientName=
     I: Requesting Association
     I: Association Accepted
     I: Sending Move Request: MsgID 1
@@ -32,7 +38,6 @@ Service* with the default Move AE title ``STORESCP``:
     I: Move SCP Result: 0x0000 (Success)
     I: Sub-Operations Remaining: 0, Completed: 1, Failed: 0, Warning: 0
     I: Releasing Association
-    user@host:
 
 The Move AE title can be specified using the ``-aem aetitle`` flag.
 
@@ -43,7 +48,7 @@ the Store SCP can be configured using the ``--store-aet`` and
 
 .. code-block:: text
 
-    user@host: python movescu.py 127.0.0.1 11112 -k QueryRetrieveLevel=PATIENT -k PatientName= --store
+    $ python -m pynetdicom movescu 127.0.0.1 11112 -k QueryRetrieveLevel=PATIENT -k PatientName= --store
     I: Requesting Association
     I: Association Accepted
     I: Sending Move Request: MsgID 1
@@ -62,7 +67,6 @@ the Store SCP can be configured using the ``--store-aet`` and
     I: Move SCP Result: 0x0000 (Success)
     I: Sub-Operations Remaining: 0, Completed: 1, Failed: 0, Warning: 0
     I: Releasing Association
-    user@host:
 
 
 Parameters
@@ -117,7 +121,7 @@ Storage SCP Options
 Query Information Model Options
 -------------------------------
 ``-P    --patient``
-            use patient root information model
+            use patient root information model (default)
 ``-S    --study``
             use study root information model
 ``-O    --psonly``
@@ -136,7 +140,7 @@ Query Options
 
 Output Options
 --------------
-``-od [d]irectory, --output-directory [d]irectory (strS)``
+``-od [d]irectory, --output-directory [d]irectory (str)``
             write received objects to directory ``d`` (with ``--store``)
 ``--ignore``
             receive data but don't store it (with ``--store``)
diff --git a/docs/apps/storescp.rst b/docs/apps/storescp.rst
index b6ebd19f8..821bc6619 100644
--- a/docs/apps/storescp.rst
+++ b/docs/apps/storescp.rst
@@ -1,7 +1,10 @@
 ========
 storescp
 ========
-    ``storescp.py [options] port``
+
+.. code-block:: text
+
+    $ python -m pynetdicom storescp [options] port
 
 Description
 ===========
@@ -12,12 +15,15 @@ Association is established, allows Storage SCUs transfer SOP Instances
 with SOP Classes matching the presentation contexts accepted during Association
 negotation.
 
+Usage
+=====
+
 The following example shows what happens when it is started and receives
 a C-STORE request from a peer:
 
 .. code-block:: text
 
-   user@host: python storescp.py 11112
+   $ python -m pynetdicom storescp 11112
 
 
 More information is available when a connection is received while running with
@@ -25,7 +31,7 @@ the ``-v`` option:
 
 .. code-block:: text
 
-    user@host: python storescp.py 11112 -v
+    $ python -m pynetdicom storescp 11112 -v
     I: Accepting Association
     I: Received Store Request
     I: Storing DICOM file: CT.1.3.6.1.4.1.5962.1.1.1.1.1.20040119072730.12322
@@ -36,7 +42,7 @@ running with the ``-d`` option:
 
 .. code-block:: text
 
-    user@host: python storescp.py 11112 -d
+    $ python -m pynetdicom storescp 11112 -d
     D: storescp.py v0.6.0
     D:
     D: Request Parameters:
@@ -100,16 +106,21 @@ Preferred Transfer Syntaxes
 
 Output
 ------
-``-od [d]irectory, --output-directory [d]irectory``
+``-od [d]irectory, --output-directory [d]irectory (str)``
             write received objects to directory ``d``
 ``--ignore``
             receive data but don't store it
 
+Miscellaneous
+-------------
+``--no-echo``
+            don't act as a verification SCP
+
 
 DICOM Conformance
 =================
-The ``storescp`` application supports the Verification and Storage Service
-Classes as an SCP with the following SOP Classes:
+The ``storescp`` application supports the Verification (unless ``--no-echo``
+is used) and Storage Service Classes as an SCP with the following SOP Classes:
 
 Verification Service Class
 --------------------------
diff --git a/docs/apps/storescu.rst b/docs/apps/storescu.rst
index 59200e94f..9e753e513 100644
--- a/docs/apps/storescu.rst
+++ b/docs/apps/storescu.rst
@@ -1,7 +1,10 @@
 ========
 storescu
 ========
-    ``storescu.py [options] addr port dcmfile-in``
+
+.. code-block:: text
+
+    $ python -m pynetdicom storescu [options] addr port path
 
 Description
 ===========
@@ -9,30 +12,32 @@ The ``storescu`` application implements a *Service Class User* (SCU) for
 the :dcm:`Storage Service Class<part04/chapter_B.html>`. It requests an
 association with a peer Application Entity on IP address ``addr`` and listen
 port ``port`` and once established requests the transfer
-of the SOP Instance in ``dcmfile-in``.
+of the SOP Instance at ``path`` if ``path`` is a file, or the SOP Instances in
+``path`` if ``path`` is a directory.
+
+Usage
+=====
 
 The following example shows what happens when it is succesfully run on
-an SCP at IP 127.0.0.1 and listen port 11112 that supports the *Storage
+an SCP at IP ``127.0.0.1`` and listen port ``11112`` that supports the *Storage
 Service*:
 
 .. code-block:: text
 
-    user@host: python storescu.py 127.0.0.1 11112 path/to/file
-    user@host:
+    $ python -m pynetdicom storescu 127.0.0.1 11112 path/to/dataset
 
 When attempting to use the SCP with an unsupported SOP Class:
 
 .. code-block:: text
 
-    user@host: python storescu.py 127.0.0.1 11112 path/to/file
+    $ python -m pynetdicom storescu 127.0.0.1 11112 path/to/file
     E: No accepted presentation contexts
-    user@host:
 
 More information is available with the ``-d`` flag:
 
 .. code-block:: text
 
-    user@host: python storescu.py 127.0.0.1 11112 path/to/file -d
+    $ python -m pynetdicom storescu 127.0.0.1 11112 path/to/file -d
     D: storescu.py v0.3.0
     D:
     D: Checking input file
@@ -63,7 +68,6 @@ More information is available with the ``-d`` flag:
     D: Status                        : 0x0000 - Success
     D: ============================ END DIMSE MESSAGE =============================
     I: Releasing Association
-    user@host:
 
 Parameters
 ==========
@@ -71,8 +75,8 @@ Parameters
             TCP/IP address or hostname of DICOM peer
 ``port``
             TCP/IP port number of peer
-``dcmfile-in``
-            DICOM file to be transmitted
+``path``
+            path to the DICOM file or directory to be transmitted
 
 Options
 =======
@@ -89,6 +93,11 @@ General Options
             ``'debug'``], prints logging messages with corresponding level
             or higher
 
+Input Options
+-------------
+``-r    --recurse``
+            recursively search the given directory
+
 Network Options
 ---------------
 ``-aet  --calling-aet [a]etitle (str)``
@@ -117,8 +126,8 @@ Transfer Syntax Options
 Miscellaneous Options
 ---------------------
 ``-cx   --single-context``
-            only request a single presentation context that matches the input
-            DICOM file
+            only request the presentation contexts required for the
+            input DICOM file(s)
 
 
 DICOM Conformance
@@ -126,8 +135,8 @@ DICOM Conformance
 The storescu application supports all of the *Storage Service Class'* supported
 SOP Classes as SCU.
 
-The application will request presentation contexts using these transfer
-syntaxes:
+By default, the application will request presentation contexts using these
+transfer syntaxes:
 
 +------------------------+----------------------------------------------------+
 | UID                    | Transfer Syntax                                    |
diff --git a/docs/changelog/v1.5.0.rst b/docs/changelog/v1.5.0.rst
index 7de83e6b6..c8ada4bbb 100644
--- a/docs/changelog/v1.5.0.rst
+++ b/docs/changelog/v1.5.0.rst
@@ -51,12 +51,16 @@ Enhancements
   contains all of the Storage Service Class' SOP Class UIDs rather than just
   the first 128 as in
   :attr:`~pynetdicom.presentation.StoragePresentationContexts`.
-* Overhaul of ``echoscp.py``, ``echoscu.py``, ``storescp.py`` and
-  ``storescu.py``
-* Added non-toy implementations for ``findscu.py``, ``getscu.py`` and
-  ``movescu.py`` apps
+* Overhaul of :doc:`echoscp <../apps/echoscp>`,
+  :doc:`echoscu <../apps/echoscu>`, :doc:`storescp <../apps/storescp>` and
+  :doc:`storescu <../apps/storescu>` apps
+* Added non-toy implementations for :doc:`findscu <../apps/findscu>`,
+  :doc:`getscu <../apps/getscu>` and :doc:`movescu <../apps/movescu>` apps
+* Apps can now be invoked from the command line with
+  ``python -m pynetdicom [appname] [options]``, e.g. ``python -m pynetdicom
+  echoscu 127.0.0.1 11112 -d``
 * Added ability to pass optional extra arguments to event handlers by using
-  ``(evt.EVT_NAME, handler, ['list', 'of', 'args'])``. (:issue:`447`)
+  ``(evt.EVT_NAME, handler, ['list', 'of', 'args'])`` (:issue:`447`)
 * Added :meth:`~pynetdicom.ae.AE.make_server` (:issue:`453`).
 * Added `request_handler` keyword parameter to
   :class:`~pynetdicom.transport.AssociationServer` (:issue:`453`)
diff --git a/pynetdicom/__main__.py b/pynetdicom/__main__.py
new file mode 100644
index 000000000..e209453ca
--- /dev/null
+++ b/pynetdicom/__main__.py
@@ -0,0 +1,28 @@
+"""CLI interface for pynetdicom"""
+
+import importlib
+import sys
+
+from pynetdicom import __version__
+
+
+_APPS = {
+    'echoscp': 'pynetdicom.apps.echoscp.echoscp',
+    'echoscu': 'pynetdicom.apps.echoscu.echoscu',
+    'findscu': 'pynetdicom.apps.findscu.findscu',
+    'getscu': 'pynetdicom.apps.getscu.getscu',
+    'movescu': 'pynetdicom.apps.movescu.movescu',
+    'qrscp': 'pynetdicom.apps.qrscp.qrscp',
+    'storescp': 'pynetdicom.apps.storescp.storescp',
+    'storescu': 'pynetdicom.apps.storescu.storescu',
+}
+
+
+if __name__ == "__main__":
+    args = sys.argv[1:]
+    if args[0] == '--version':
+        print(__version__)
+    else:
+        app_path = _APPS[args[0]]
+        app = importlib.import_module(app_path)
+        app.main(args)
diff --git a/pynetdicom/apps/echoscp/__init__.py b/pynetdicom/apps/echoscp/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/pynetdicom/apps/echoscp/echoscp.py b/pynetdicom/apps/echoscp/echoscp.py
index bbfca1358..739b6e059 100755
--- a/pynetdicom/apps/echoscp/echoscp.py
+++ b/pynetdicom/apps/echoscp/echoscp.py
@@ -153,7 +153,11 @@ def handle_echo(event):
     return 0x0000
 
 
-if __name__ == "__main__":
+def main(args=None):
+    """Run the application."""
+    if args is not None:
+        sys.argv = args
+
     args = _setup_argparser()
 
     if args.version:
@@ -190,3 +194,7 @@ if __name__ == "__main__":
     ae.dimse_timeout = args.dimse_timeout
 
     ae.start_server((args.bind_address, args.port), evt_handlers=handlers)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/pynetdicom/apps/echoscu/__init__.py b/pynetdicom/apps/echoscu/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/pynetdicom/apps/echoscu/echoscu.py b/pynetdicom/apps/echoscu/echoscu.py
index 2d52752a1..dcd954f72 100755
--- a/pynetdicom/apps/echoscu/echoscu.py
+++ b/pynetdicom/apps/echoscu/echoscu.py
@@ -156,7 +156,11 @@ def _setup_argparser():
     return parser.parse_args()
 
 
-if __name__ == "__main__":
+def main(args=None):
+    """Run the application."""
+    if args is not None:
+        sys.argv = args
+
     args = _setup_argparser()
 
     if args.version:
@@ -210,3 +214,7 @@ if __name__ == "__main__":
     else:
         # Failed to associate: timeout, refused, connection closed, aborted
         sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/pynetdicom/apps/findscu/__init__.py b/pynetdicom/apps/findscu/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/pynetdicom/apps/findscu/findscu.py b/pynetdicom/apps/findscu/findscu.py
index 12425a0d6..9f8bb9613 100755
--- a/pynetdicom/apps/findscu/findscu.py
+++ b/pynetdicom/apps/findscu/findscu.py
@@ -142,7 +142,7 @@ def _setup_argparser():
     qr_model = qr_group.add_mutually_exclusive_group()
     qr_model.add_argument(
         "-P", "--patient",
-        help="use patient root information model",
+        help="use patient root information model (default)",
         action="store_true"
     )
     qr_model.add_argument(
@@ -226,7 +226,11 @@ def generate_filename():
         ii += 1
 
 
-if __name__ == '__main__':
+def main(args=None):
+    """Run the application."""
+    if args is not None:
+        sys.argv = args
+
     args = _setup_argparser()
 
     if args.version:
@@ -282,12 +286,13 @@ if __name__ == '__main__':
     if assoc.is_established:
         # Send C-FIND request, `responses` is a generator
         responses = assoc.send_c_find(identifier, query_model)
+        # Ysed to generate filenames if args.write used
+        fname = generate_filename()
         for (status, rsp_identifier) in responses:
             # If `status.Status` is one of the 'Pending' statuses then
             #   `rsp_identifier` is the C-FIND response's Identifier dataset
             if status and status.Status in [0xFF00, 0xFF01]:
                 if args.write:
-                    fname = generate_filename()
                     rsp_identifier.file_meta = get_file_meta(
                         assoc, query_model
                     )
@@ -299,3 +304,7 @@ if __name__ == '__main__':
         assoc.release()
     else:
         sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/pynetdicom/apps/getscu/__init__.py b/pynetdicom/apps/getscu/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/pynetdicom/apps/getscu/getscu.py b/pynetdicom/apps/getscu/getscu.py
index bbcd27766..7169fcb9b 100755
--- a/pynetdicom/apps/getscu/getscu.py
+++ b/pynetdicom/apps/getscu/getscu.py
@@ -137,7 +137,7 @@ def _setup_argparser():
     qr_model = qr_group.add_mutually_exclusive_group()
     qr_model.add_argument(
         "-P", "--patient",
-        help="use patient root information model",
+        help="use patient root information model (default)",
         action="store_true"
     )
     qr_model.add_argument(
@@ -196,7 +196,11 @@ def _setup_argparser():
     return ns
 
 
-if __name__ == "__main__":
+def main(args=None):
+    """Run the application."""
+    if args is not None:
+        sys.argv = args
+
     args = _setup_argparser()
 
     if args.version:
@@ -278,3 +282,7 @@ if __name__ == "__main__":
         assoc.release()
     else:
         sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/pynetdicom/apps/movescu/__init__.py b/pynetdicom/apps/movescu/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/pynetdicom/apps/movescu/movescu.py b/pynetdicom/apps/movescu/movescu.py
index 5ff601901..f6b3028b6 100755
--- a/pynetdicom/apps/movescu/movescu.py
+++ b/pynetdicom/apps/movescu/movescu.py
@@ -140,7 +140,7 @@ def _setup_argparser():
     qr_model = qr_group.add_mutually_exclusive_group()
     qr_model.add_argument(
         "-P", "--patient",
-        help="use patient root information model",
+        help="use patient root information model (default)",
         action="store_true"
     )
     qr_model.add_argument(
@@ -220,7 +220,11 @@ def _setup_argparser():
     return ns
 
 
-if __name__ == "__main__":
+def main(args=None):
+    """Run the application."""
+    if args is not None:
+        sys.argv = args
+
     args = _setup_argparser()
 
     if args.version:
@@ -300,3 +304,7 @@ if __name__ == "__main__":
         scp.shutdown()
 
     sys.exit(_EXIT_VALUE)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/pynetdicom/apps/storescp/__init__.py b/pynetdicom/apps/storescp/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/pynetdicom/apps/storescp/storescp.py b/pynetdicom/apps/storescp/storescp.py
index 65408dd3d..52c55a1c0 100755
--- a/pynetdicom/apps/storescp/storescp.py
+++ b/pynetdicom/apps/storescp/storescp.py
@@ -162,10 +162,22 @@ def _setup_argparser():
         action="store_true"
     )
 
+    # Miscellaneous Options
+    misc_opts = parser.add_argument_group('Miscellaneous Options')
+    misc_opts.add_argument(
+        "--no-echo",
+        help="don't act as a verification SCP",
+        action="store_true"
+    )
+
     return parser.parse_args()
 
 
-if __name__ == "__main__":
+def main(args=None):
+    """Run the application."""
+    if args is not None:
+        sys.argv = args
+
     args = _setup_argparser()
 
     if args.version:
@@ -199,8 +211,10 @@ if __name__ == "__main__":
     # Add presentation contexts with specified transfer syntaxes
     for context in AllStoragePresentationContexts:
         ae.add_supported_context(context.abstract_syntax, transfer_syntax)
-    for context in VerificationPresentationContexts:
-        ae.add_supported_context(context.abstract_syntax, transfer_syntax)
+
+    if not args.no_echo:
+        for context in VerificationPresentationContexts:
+            ae.add_supported_context(context.abstract_syntax, transfer_syntax)
 
     ae.maximum_pdu_size = args.max_pdu
 
@@ -210,3 +224,7 @@ if __name__ == "__main__":
     ae.dimse_timeout = args.dimse_timeout
 
     ae.start_server((args.bind_address, args.port), evt_handlers=handlers)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/pynetdicom/apps/storescu/__init__.py b/pynetdicom/apps/storescu/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/pynetdicom/apps/storescu/storescu.py b/pynetdicom/apps/storescu/storescu.py
index f519c030c..3e94887ca 100755
--- a/pynetdicom/apps/storescu/storescu.py
+++ b/pynetdicom/apps/storescu/storescu.py
@@ -33,7 +33,7 @@ def _setup_argparser():
             "file on the command line it sends a C-STORE message to a "
             "Storage Service Class Provider (SCP) and waits for a response."
         ),
-        usage="storescu [options] addr port dcmfile"
+        usage="storescu [options] addr port path"
     )
 
     # Parameters
@@ -43,7 +43,7 @@ def _setup_argparser():
     )
     req_opts.add_argument("port", help="TCP/IP port number of peer", type=int)
     req_opts.add_argument(
-        "dcmfile", metavar="dcmfile", nargs='+',
+        "path", metavar="path", nargs='+',
         help="DICOM file or directory to be transmitted",
         type=str
     )
@@ -166,7 +166,7 @@ def _setup_argparser():
     return parser.parse_args()
 
 
-def get_contexts(fpaths):
+def get_contexts(fpaths, app_logger):
     """Return the valid DICOM files and their context values.
 
     Parameters
@@ -204,12 +204,16 @@ def get_contexts(fpaths):
         good.append(fpath)
 
     for (reason, fpath) in bad:
-        APP_LOGGER.error("{}: {}".format(reason, fpath))
+        app_logger.error("{}: {}".format(reason, fpath))
 
     return good, contexts
 
 
-if __name__ == "__main__":
+def main(args=None):
+    """Run the application."""
+    if args is not None:
+        sys.argv = args
+
     args = _setup_argparser()
 
     if args.version:
@@ -220,7 +224,7 @@ if __name__ == "__main__":
     APP_LOGGER.debug('storescu.py v{0!s}'.format(__version__))
     APP_LOGGER.debug('')
 
-    lfiles, badfiles = get_files(args.dcmfile, args.recurse)
+    lfiles, badfiles = get_files(args.path, args.recurse)
 
     for bad in badfiles:
         APP_LOGGER.error("Cannot access path: {}".format(bad))
@@ -232,7 +236,7 @@ if __name__ == "__main__":
 
     if args.required_contexts:
         # Only propose required presentation contexts
-        lfiles, contexts = get_contexts(lfiles)
+        lfiles, contexts = get_contexts(lfiles, APP_LOGGER)
         if len(contexts) > 128:
             raise ValueError(
                 "More than 128 presentation contexts required with the "
@@ -288,3 +292,7 @@ if __name__ == "__main__":
         assoc.release()
     else:
         sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()


Test patch:
diff --git a/pynetdicom/apps/tests/test_echoscp.py b/pynetdicom/apps/tests/test_echoscp.py
index 5b990691c..9b247e794 100644
--- a/pynetdicom/apps/tests/test_echoscp.py
+++ b/pynetdicom/apps/tests/test_echoscp.py
@@ -50,12 +50,19 @@ def start_echoscp(args):
     return subprocess.Popen(pargs)
 
 
-class TestEchoSCP(object):
+def start_echoscp_cli(args):
+    """Start the echoscp app using CLI and return the process."""
+    pargs = [which('python'), '-m', 'pynetdicom', 'echoscp', '11112'] + [*args]
+    return subprocess.Popen(pargs)
+
+
+class EchoSCPBase(object):
     """Tests for echoscp.py"""
     def setup(self):
         """Run prior to each test"""
         self.ae = None
         self.p = None
+        self.func = None
 
     def teardown(self):
         """Clear any active threads"""
@@ -74,7 +81,7 @@ class TestEchoSCP(object):
         ae.network_timeout = 5
         ae.add_requested_context(VerificationSOPClass)
 
-        self.p = p = start_echoscp([])
+        self.p = p = self.func([])
         time.sleep(0.5)
 
         assoc = ae.associate('localhost', 11112)
@@ -93,7 +100,7 @@ class TestEchoSCP(object):
 
     def test_flag_version(self, capfd):
         """Test --version flag."""
-        self.p = p = start_echoscp(['--version'])
+        self.p = p = self.func(['--version'])
         p.wait()
         assert p.returncode == 0
 
@@ -108,7 +115,7 @@ class TestEchoSCP(object):
         ae.network_timeout = 5
         ae.add_requested_context(VerificationSOPClass)
 
-        self.p = p = start_echoscp(['-q'])
+        self.p = p = self.func(['-q'])
         time.sleep(0.5)
 
         assoc = ae.associate('localhost', 11112)
@@ -133,7 +140,7 @@ class TestEchoSCP(object):
 
         out, err = [], []
 
-        self.p = p = start_echoscp(['-v'])
+        self.p = p = self.func(['-v'])
         time.sleep(0.5)
 
         assoc = ae.associate('localhost', 11112)
@@ -158,7 +165,7 @@ class TestEchoSCP(object):
         ae.network_timeout = 5
         ae.add_requested_context(VerificationSOPClass)
 
-        self.p = p = start_echoscp(['-d'])
+        self.p = p = self.func(['-d'])
         time.sleep(0.5)
 
         assoc = ae.associate('localhost', 11112)
@@ -176,7 +183,7 @@ class TestEchoSCP(object):
 
     def test_flag_log_collision(self):
         """Test error with -q -v and -d flag."""
-        self.p = p = start_echoscp(['-v', '-d'])
+        self.p = p = self.func(['-v', '-d'])
         p.wait()
         assert p.returncode != 0
 
@@ -208,7 +215,7 @@ class TestEchoSCP(object):
         ae.network_timeout = 5
         ae.add_requested_context(VerificationSOPClass)
 
-        self.p = p = start_echoscp(['--max-pdu', '123456'])
+        self.p = p = self.func(['--max-pdu', '123456'])
         time.sleep(0.5)
 
         assoc = ae.associate('localhost', 11112)
@@ -228,7 +235,7 @@ class TestEchoSCP(object):
         ae.network_timeout = 5
         ae.add_requested_context(VerificationSOPClass)
 
-        self.p = p = start_echoscp(['-x='])
+        self.p = p = self.func(['-x='])
         time.sleep(0.5)
 
         assoc = ae.associate('localhost', 11112)
@@ -249,7 +256,7 @@ class TestEchoSCP(object):
         ae.network_timeout = 5
         ae.add_requested_context(VerificationSOPClass)
 
-        self.p = p = start_echoscp(['-xe'])
+        self.p = p = self.func(['-xe'])
         time.sleep(0.5)
 
         assoc = ae.associate('localhost', 11112)
@@ -270,7 +277,7 @@ class TestEchoSCP(object):
         ae.network_timeout = 5
         ae.add_requested_context(VerificationSOPClass)
 
-        self.p = p = start_echoscp(['-xb'])
+        self.p = p = self.func(['-xb'])
         time.sleep(0.5)
 
         assoc = ae.associate('localhost', 11112)
@@ -291,7 +298,7 @@ class TestEchoSCP(object):
         ae.network_timeout = 5
         ae.add_requested_context(VerificationSOPClass)
 
-        self.p = p = start_echoscp(['-xi'])
+        self.p = p = self.func(['-xi'])
         time.sleep(0.5)
 
         assoc = ae.associate('localhost', 11112)
@@ -303,3 +310,21 @@ class TestEchoSCP(object):
 
         cx = assoc.accepted_contexts[0]
         assert cx.transfer_syntax[0] == ImplicitVRLittleEndian
+
+
+class TestEchoSCP(EchoSCPBase):
+    """Tests for echoscp.py"""
+    def setup(self):
+        """Run prior to each test"""
+        self.ae = None
+        self.p = None
+        self.func = start_echoscp
+
+
+class TestEchoSCPCLI(EchoSCPBase):
+    """Tests for echoscp using CLI"""
+    def setup(self):
+        """Run prior to each test"""
+        self.ae = None
+        self.p = None
+        self.func = start_echoscp_cli
diff --git a/pynetdicom/apps/tests/test_echoscu.py b/pynetdicom/apps/tests/test_echoscu.py
index e125914df..a598327c8 100644
--- a/pynetdicom/apps/tests/test_echoscu.py
+++ b/pynetdicom/apps/tests/test_echoscu.py
@@ -47,11 +47,20 @@ def start_echoscu(args):
     return subprocess.Popen(pargs)
 
 
-class TestEchoSCU(object):
+def start_echoscu_cli(args):
+    """Start the echoscu app using CLI and return the process."""
+    pargs = [
+        which('python'), '-m', 'pynetdicom', 'echoscu', 'localhost', '11112'
+    ] + [*args]
+    return subprocess.Popen(pargs)
+
+
+class EchoSCUBase(object):
     """Tests for echoscu.py"""
     def setup(self):
         """Run prior to each test"""
         self.ae = None
+        self.func = None
 
     def teardown(self):
         """Clear any active threads"""
@@ -81,7 +90,7 @@ class TestEchoSCU(object):
         ae.add_supported_context(VerificationSOPClass)
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_echoscu([])
+        p = self.func([])
         p.wait()
         assert p.returncode == 0
 
@@ -110,7 +119,7 @@ class TestEchoSCU(object):
 
     def test_no_peer(self, capfd):
         """Test trying to connect to non-existent host."""
-        p = start_echoscu([])
+        p = self.func([])
         p.wait()
         assert p.returncode == 1
 
@@ -121,7 +130,7 @@ class TestEchoSCU(object):
 
     def test_flag_version(self, capfd):
         """Test --version flag."""
-        p = start_echoscu(['--version'])
+        p = self.func(['--version'])
         p.wait()
         assert p.returncode == 0
 
@@ -137,7 +146,7 @@ class TestEchoSCU(object):
         ae.add_supported_context(CTImageStorage)
         scp = ae.start_server(('', 11112), block=False)
 
-        p = start_echoscu(['-q'])
+        p = self.func(['-q'])
         p.wait()
         assert p.returncode == 1
 
@@ -155,7 +164,7 @@ class TestEchoSCU(object):
         ae.add_supported_context(VerificationSOPClass)
         scp = ae.start_server(('', 11112), block=False)
 
-        p = start_echoscu(['-v'])
+        p = self.func(['-v'])
         p.wait()
         assert p.returncode == 0
 
@@ -178,7 +187,7 @@ class TestEchoSCU(object):
         ae.add_supported_context(VerificationSOPClass)
         scp = ae.start_server(('', 11112), block=False)
 
-        p = start_echoscu(['-d'])
+        p = self.func(['-d'])
         p.wait()
         assert p.returncode == 0
 
@@ -190,7 +199,7 @@ class TestEchoSCU(object):
 
     def test_flag_log_collision(self):
         """Test error with -q -v and -d flag."""
-        p = start_echoscu(['-q', '-v'])
+        p = self.func(['-q', '-v'])
         p.wait()
         assert p.returncode != 0
 
@@ -222,7 +231,7 @@ class TestEchoSCU(object):
         ae.add_supported_context(VerificationSOPClass)
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_echoscu(['-aet', 'MYSCU'])
+        p = self.func(['-aet', 'MYSCU'])
         p.wait()
         assert p.returncode == 0
 
@@ -256,7 +265,7 @@ class TestEchoSCU(object):
         ae.add_supported_context(VerificationSOPClass)
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_echoscu(['-aec', 'YOURSCP'])
+        p = self.func(['-aec', 'YOURSCP'])
         p.wait()
         assert p.returncode == 0
 
@@ -295,7 +304,7 @@ class TestEchoSCU(object):
         ae.add_supported_context(VerificationSOPClass)
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_echoscu(['-ta', '0.05', '-d'])
+        p = self.func(['-ta', '0.05', '-d'])
         p.wait()
         assert p.returncode == 1
 
@@ -332,7 +341,7 @@ class TestEchoSCU(object):
         ae.add_supported_context(VerificationSOPClass)
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_echoscu(['-td', '0.05', '-d'])
+        p = self.func(['-td', '0.05', '-d'])
         p.wait()
         assert p.returncode == 0
 
@@ -373,7 +382,7 @@ class TestEchoSCU(object):
         ae.add_supported_context(VerificationSOPClass)
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_echoscu(['--max-pdu', '123456'])
+        p = self.func(['--max-pdu', '123456'])
         p.wait()
         assert p.returncode == 0
 
@@ -407,7 +416,7 @@ class TestEchoSCU(object):
         ae.add_supported_context(VerificationSOPClass)
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_echoscu(['-xe'])
+        p = self.func(['-xe'])
         p.wait()
         assert p.returncode == 0
 
@@ -444,7 +453,7 @@ class TestEchoSCU(object):
         ae.add_supported_context(VerificationSOPClass)
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_echoscu(['-xb'])
+        p = self.func(['-xb'])
         p.wait()
         assert p.returncode == 0
 
@@ -481,7 +490,7 @@ class TestEchoSCU(object):
         ae.add_supported_context(VerificationSOPClass)
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_echoscu(['-xi'])
+        p = self.func(['-xi'])
         p.wait()
         assert p.returncode == 0
 
@@ -518,7 +527,7 @@ class TestEchoSCU(object):
         ae.add_supported_context(VerificationSOPClass)
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_echoscu(['--repeat', '3'])
+        p = self.func(['--repeat', '3'])
         p.wait()
         assert p.returncode == 0
 
@@ -556,7 +565,7 @@ class TestEchoSCU(object):
         ae.add_supported_context(VerificationSOPClass)
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_echoscu(['--abort'])
+        p = self.func(['--abort'])
         p.wait()
         assert p.returncode == 0
 
@@ -564,3 +573,19 @@ class TestEchoSCU(object):
 
         assert events[0].event == evt.EVT_C_ECHO
         assert events[1].event == evt.EVT_ABORTED
+
+
+class TestEchoSCU(EchoSCUBase):
+    """Tests for echoscu.py"""
+    def setup(self):
+        """Run prior to each test"""
+        self.ae = None
+        self.func = start_echoscu
+
+
+class TestEchoSCUCLI(EchoSCUBase):
+    """Tests for echoscu using CLI"""
+    def setup(self):
+        """Run prior to each test"""
+        self.ae = None
+        self.func = start_echoscu_cli
diff --git a/pynetdicom/apps/tests/test_findscu.py b/pynetdicom/apps/tests/test_findscu.py
index 3bc11eecf..898aea16d 100644
--- a/pynetdicom/apps/tests/test_findscu.py
+++ b/pynetdicom/apps/tests/test_findscu.py
@@ -60,11 +60,20 @@ def start_findscu(args):
     return subprocess.Popen(pargs)
 
 
-class TestFindSCU(object):
+def start_findscu_scli(args):
+    """Start the findscu app using CLI and return the process."""
+    pargs = [
+        which('python'), '-m', 'pynetdicom', 'findscu', 'localhost', '11112'
+    ] + [*args]
+    return subprocess.Popen(pargs)
+
+
+class FindSCUBase(object):
     """Tests for findscu.py"""
     def setup(self):
         """Run prior to each test"""
         self.ae = None
+        self.func = None
 
     def teardown(self):
         """Clear any active threads"""
@@ -97,7 +106,7 @@ class TestFindSCU(object):
         )
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_findscu(['-k', "PatientName="])
+        p = self.func(['-k', "PatientName="])
         p.wait()
         assert p.returncode == 0
 
@@ -124,7 +133,7 @@ class TestFindSCU(object):
 
     def test_no_peer(self, capfd):
         """Test trying to connect to non-existent host."""
-        p = start_findscu(['-k', "PatientName="])
+        p = self.func(['-k', "PatientName="])
         p.wait()
         assert p.returncode == 1
 
@@ -135,7 +144,7 @@ class TestFindSCU(object):
 
     def test_bad_input(self, capfd):
         """Test being unable to read the input file."""
-        p = start_findscu(['-f', 'no-such-file.dcm'])
+        p = self.func(['-f', 'no-such-file.dcm'])
         p.wait()
         assert p.returncode == 1
 
@@ -144,7 +153,7 @@ class TestFindSCU(object):
 
     def test_flag_version(self, capfd):
         """Test --version flag."""
-        p = start_findscu(['--version'])
+        p = self.func(['--version'])
         p.wait()
         assert p.returncode == 0
 
@@ -160,7 +169,7 @@ class TestFindSCU(object):
         ae.add_supported_context(VerificationSOPClass)
         scp = ae.start_server(('', 11112), block=False)
 
-        p = start_findscu(['-q', '-k', 'PatientName='])
+        p = self.func(['-q', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 1
 
@@ -188,7 +197,7 @@ class TestFindSCU(object):
         )
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_findscu(['-v', '-k', 'PatientName='])
+        p = self.func(['-v', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
@@ -221,7 +230,7 @@ class TestFindSCU(object):
         )
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_findscu(['-d', '-k', 'PatientName='])
+        p = self.func(['-d', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
@@ -233,7 +242,7 @@ class TestFindSCU(object):
 
     def test_flag_log_collision(self):
         """Test error with -q -v and -d flag."""
-        p = start_findscu(['-v', '-d'])
+        p = self.func(['-v', '-d'])
         p.wait()
         assert p.returncode != 0
 
@@ -263,7 +272,7 @@ class TestFindSCU(object):
         )
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_findscu(['-aet', 'MYSCU', '-k', 'PatientName='])
+        p = self.func(['-aet', 'MYSCU', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
@@ -295,7 +304,7 @@ class TestFindSCU(object):
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
 
-        p = start_findscu(['-aec', 'YOURSCP', '-k', 'PatientName='])
+        p = self.func(['-aec', 'YOURSCP', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
@@ -336,7 +345,7 @@ class TestFindSCU(object):
         )
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_findscu(['-ta', '0.05', '-d', '-k', 'PatientName='])
+        p = self.func(['-ta', '0.05', '-d', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 1
 
@@ -377,7 +386,7 @@ class TestFindSCU(object):
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
 
-        p = start_findscu(['-td', '0.05', '-d', '-k', 'PatientName='])
+        p = self.func(['-td', '0.05', '-d', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
@@ -422,7 +431,7 @@ class TestFindSCU(object):
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
 
-        p = start_findscu(['--max-pdu', '123456', '-k', 'PatientName='])
+        p = self.func(['--max-pdu', '123456', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
@@ -454,7 +463,7 @@ class TestFindSCU(object):
         )
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_findscu(['-P', '-k', 'PatientName='])
+        p = self.func(['-P', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
@@ -487,7 +496,7 @@ class TestFindSCU(object):
         )
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_findscu(['-S', '-k', 'PatientName='])
+        p = self.func(['-S', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
@@ -518,7 +527,7 @@ class TestFindSCU(object):
         )
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_findscu(['-O', '-k', 'PatientName='])
+        p = self.func(['-O', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
@@ -551,7 +560,7 @@ class TestFindSCU(object):
         )
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_findscu(['-W', '-k', 'PatientName='])
+        p = self.func(['-W', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
@@ -580,7 +589,7 @@ class TestFindSCU(object):
         )
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_findscu(['-w', '-k', 'PatientName='])
+        p = self.func(['-w', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
@@ -590,3 +599,19 @@ class TestFindSCU(object):
         ds = dcmread('rsp000001.dcm')
         assert ds.PatientName == ''
         os.remove('rsp000001.dcm')
+
+
+class TestFindSCU(FindSCUBase):
+    """Tests for findscu.py"""
+    def setup(self):
+        """Run prior to each test"""
+        self.ae = None
+        self.func = start_findscu
+
+
+class TestFindSCUCLI(FindSCUBase):
+    """Tests for findscu.py"""
+    def setup(self):
+        """Run prior to each test"""
+        self.ae = None
+        self.func = start_findscu_scli
diff --git a/pynetdicom/apps/tests/test_getscu.py b/pynetdicom/apps/tests/test_getscu.py
index fbb33eb10..ca37d94f0 100644
--- a/pynetdicom/apps/tests/test_getscu.py
+++ b/pynetdicom/apps/tests/test_getscu.py
@@ -60,11 +60,20 @@ def start_getscu(args):
     return subprocess.Popen(pargs)
 
 
-class TestGetSCU(object):
-    """Tests for findscu.py"""
+def start_getscu_cli(args):
+    """Start the getscu app using CLI and return the process."""
+    pargs = [
+        which('python'), '-m', 'pynetdicom', 'getscu', 'localhost', '11112'
+    ] + [*args]
+    return subprocess.Popen(pargs)
+
+
+class GetSCUBase(object):
+    """Tests for getscu.py"""
     def setup(self):
         """Run prior to each test"""
         self.ae = None
+        self.func = None
 
         self.response = ds = Dataset()
         ds.file_meta = Dataset()
@@ -102,7 +111,7 @@ class TestGetSCU(object):
         ae.supported_contexts = QueryRetrievePresentationContexts
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_getscu(['-k', "PatientName="])
+        p = self.func(['-k', "PatientName="])
         p.wait()
         assert p.returncode == 0
 
@@ -130,7 +139,7 @@ class TestGetSCU(object):
 
     def test_no_peer(self, capfd):
         """Test trying to connect to non-existent host."""
-        p = start_getscu(['-k', "PatientName="])
+        p = self.func(['-k', "PatientName="])
         p.wait()
         assert p.returncode == 1
 
@@ -141,7 +150,7 @@ class TestGetSCU(object):
 
     def test_bad_input(self, capfd):
         """Test being unable to read the input file."""
-        p = start_getscu(['-f', 'no-such-file.dcm'])
+        p = self.func(['-f', 'no-such-file.dcm'])
         p.wait()
         assert p.returncode == 1
 
@@ -150,7 +159,7 @@ class TestGetSCU(object):
 
     def test_flag_version(self, capfd):
         """Test --version flag."""
-        p = start_getscu(['--version'])
+        p = self.func(['--version'])
         p.wait()
         assert p.returncode == 0
 
@@ -166,7 +175,7 @@ class TestGetSCU(object):
         ae.add_supported_context(VerificationSOPClass)
         scp = ae.start_server(('', 11112), block=False)
 
-        p = start_getscu(['-q', '-k', 'PatientName='])
+        p = self.func(['-q', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 1
 
@@ -192,7 +201,7 @@ class TestGetSCU(object):
         ae.supported_contexts = QueryRetrievePresentationContexts
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_getscu(['-v', '-k', 'PatientName='])
+        p = self.func(['-v', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
@@ -223,7 +232,7 @@ class TestGetSCU(object):
         ae.supported_contexts = QueryRetrievePresentationContexts
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_getscu(['-d', '-k', 'PatientName='])
+        p = self.func(['-d', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
@@ -235,7 +244,7 @@ class TestGetSCU(object):
 
     def test_flag_log_collision(self):
         """Test error with -q -v and -d flag."""
-        p = start_getscu(['-v', '-d'])
+        p = self.func(['-v', '-d'])
         p.wait()
         assert p.returncode != 0
 
@@ -263,7 +272,7 @@ class TestGetSCU(object):
         ae.supported_contexts = QueryRetrievePresentationContexts
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_getscu(['-aet', 'MYSCU', '-k', 'PatientName='])
+        p = self.func(['-aet', 'MYSCU', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
@@ -293,7 +302,7 @@ class TestGetSCU(object):
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
 
-        p = start_getscu(['-aec', 'YOURSCP', '-k', 'PatientName='])
+        p = self.func(['-aec', 'YOURSCP', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
@@ -332,7 +341,7 @@ class TestGetSCU(object):
         ae.supported_contexts = QueryRetrievePresentationContexts
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_getscu(['-ta', '0.05', '-d', '-k', 'PatientName='])
+        p = self.func(['-ta', '0.05', '-d', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 1
 
@@ -371,7 +380,7 @@ class TestGetSCU(object):
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
 
-        p = start_getscu(['-td', '0.05', '-d', '-k', 'PatientName='])
+        p = self.func(['-td', '0.05', '-d', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
@@ -414,7 +423,7 @@ class TestGetSCU(object):
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
 
-        p = start_getscu(['--max-pdu', '123456', '-k', 'PatientName='])
+        p = self.func(['--max-pdu', '123456', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
@@ -444,7 +453,7 @@ class TestGetSCU(object):
         ae.supported_contexts = QueryRetrievePresentationContexts
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_getscu(['-P', '-k', 'PatientName='])
+        p = self.func(['-P', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
@@ -475,7 +484,7 @@ class TestGetSCU(object):
         ae.supported_contexts = QueryRetrievePresentationContexts
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_getscu(['-S', '-k', 'PatientName='])
+        p = self.func(['-S', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
@@ -504,7 +513,7 @@ class TestGetSCU(object):
         ae.supported_contexts = QueryRetrievePresentationContexts
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_getscu(['-O', '-k', 'PatientName='])
+        p = self.func(['-O', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
@@ -536,7 +545,7 @@ class TestGetSCU(object):
 
         assert 'test_dir' not in os.listdir()
 
-        p = start_getscu(['-od', 'test_dir', '-k', 'PatientName='])
+        p = self.func(['-od', 'test_dir', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
@@ -563,10 +572,40 @@ class TestGetSCU(object):
         ae.supported_contexts = QueryRetrievePresentationContexts
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_getscu(['--ignore', '-k', 'PatientName='])
+        p = self.func(['--ignore', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
         scp.shutdown()
 
         assert 'CT.1.2.3.4' not in os.listdir()
+
+
+class TestGetSCU(GetSCUBase):
+    """Tests for getscu.py"""
+    def setup(self):
+        """Run prior to each test"""
+        self.ae = None
+        self.func = start_getscu
+
+        self.response = ds = Dataset()
+        ds.file_meta = Dataset()
+        ds.file_meta.TransferSyntaxUID = ImplicitVRLittleEndian
+        ds.SOPClassUID = CTImageStorage
+        ds.SOPInstanceUID = '1.2.3.4'
+        ds.PatientName = 'Citizen^Jan'
+
+
+class TestGetSCUCLI(GetSCUBase):
+    """Tests for getscu using CLI"""
+    def setup(self):
+        """Run prior to each test"""
+        self.ae = None
+        self.func = start_getscu_cli
+
+        self.response = ds = Dataset()
+        ds.file_meta = Dataset()
+        ds.file_meta.TransferSyntaxUID = ImplicitVRLittleEndian
+        ds.SOPClassUID = CTImageStorage
+        ds.SOPInstanceUID = '1.2.3.4'
+        ds.PatientName = 'Citizen^Jan'
diff --git a/pynetdicom/apps/tests/test_movescu.py b/pynetdicom/apps/tests/test_movescu.py
index 1a4b402ab..2717a4528 100644
--- a/pynetdicom/apps/tests/test_movescu.py
+++ b/pynetdicom/apps/tests/test_movescu.py
@@ -61,11 +61,20 @@ def start_movescu(args):
     return subprocess.Popen(pargs)
 
 
-class TestMoveSCU(object):
-    """Tests for findscu.py"""
+def start_movescu_cli(args):
+    """Start the movescu.py app using CLI and return the process."""
+    pargs = [
+        which('python'), '-m', 'pynetdicom', 'movescu', 'localhost', '11112'
+    ] + [*args]
+    return subprocess.Popen(pargs)
+
+
+class MoveSCUBase(object):
+    """Tests for movescu.py"""
     def setup(self):
         """Run prior to each test"""
         self.ae = None
+        self.func = None
 
         self.response = ds = Dataset()
         ds.file_meta = Dataset()
@@ -112,7 +121,7 @@ class TestMoveSCU(object):
             evt_handlers=[(evt.EVT_C_STORE, handle_store)]
         )
 
-        p = start_movescu(['-k', "PatientName="])
+        p = self.func(['-k', "PatientName="])
         p.wait()
         assert p.returncode == 0
 
@@ -141,7 +150,7 @@ class TestMoveSCU(object):
 
     def test_no_peer(self, capfd):
         """Test trying to connect to non-existent host."""
-        p = start_movescu(['-k', "PatientName="])
+        p = self.func(['-k', "PatientName="])
         p.wait()
         assert p.returncode == 1
 
@@ -152,7 +161,7 @@ class TestMoveSCU(object):
 
     def test_bad_input(self, capfd):
         """Test being unable to read the input file."""
-        p = start_movescu(['-f', 'no-such-file.dcm'])
+        p = self.func(['-f', 'no-such-file.dcm'])
         p.wait()
         assert p.returncode == 1
 
@@ -161,7 +170,7 @@ class TestMoveSCU(object):
 
     def test_flag_version(self, capfd):
         """Test --version flag."""
-        p = start_movescu(['--version'])
+        p = self.func(['--version'])
         p.wait()
         assert p.returncode == 0
 
@@ -177,7 +186,7 @@ class TestMoveSCU(object):
         ae.add_supported_context(VerificationSOPClass)
         scp = ae.start_server(('', 11112), block=False)
 
-        p = start_movescu(['-q', '-k', 'PatientName='])
+        p = self.func(['-q', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 1
 
@@ -213,7 +222,7 @@ class TestMoveSCU(object):
             evt_handlers=[(evt.EVT_C_STORE, handle_store)]
         )
 
-        p = start_movescu(['-v', '-k', 'PatientName='])
+        p = self.func(['-v', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
@@ -255,7 +264,7 @@ class TestMoveSCU(object):
             evt_handlers=[(evt.EVT_C_STORE, handle_store)]
         )
 
-        p = start_movescu(['-d', '-k', 'PatientName='])
+        p = self.func(['-d', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
@@ -268,7 +277,7 @@ class TestMoveSCU(object):
 
     def test_flag_log_collision(self):
         """Test error with -q -v and -d flag."""
-        p = start_movescu(['-v', '-d'])
+        p = self.func(['-v', '-d'])
         p.wait()
         assert p.returncode != 0
 
@@ -299,7 +308,7 @@ class TestMoveSCU(object):
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
         ae.supported_contexts = StoragePresentationContexts
 
-        p = start_movescu(['-aet', 'MYSCU', '-k', 'PatientName='])
+        p = self.func(['-aet', 'MYSCU', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
@@ -330,7 +339,7 @@ class TestMoveSCU(object):
         ae.supported_contexts = QueryRetrievePresentationContexts
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_movescu(['-aec', 'YOURSCP', '-k', 'PatientName='])
+        p = self.func(['-aec', 'YOURSCP', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
@@ -362,7 +371,7 @@ class TestMoveSCU(object):
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
         ae.supported_contexts = StoragePresentationContexts
 
-        p = start_movescu(['-aem', 'SOMESCP', '-k', 'PatientName='])
+        p = self.func(['-aem', 'SOMESCP', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
@@ -401,7 +410,7 @@ class TestMoveSCU(object):
         ae.supported_contexts = QueryRetrievePresentationContexts
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_movescu(['-ta', '0.05', '-d', '-k', 'PatientName='])
+        p = self.func(['-ta', '0.05', '-d', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 1
 
@@ -439,7 +448,7 @@ class TestMoveSCU(object):
         ae.supported_contexts = QueryRetrievePresentationContexts
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_movescu(['-td', '0.05', '-d', '-k', 'PatientName='])
+        p = self.func(['-td', '0.05', '-d', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
@@ -481,7 +490,7 @@ class TestMoveSCU(object):
         ae.supported_contexts = QueryRetrievePresentationContexts
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_movescu(['--max-pdu', '123456', '-k', 'PatientName='])
+        p = self.func(['--max-pdu', '123456', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
@@ -513,7 +522,7 @@ class TestMoveSCU(object):
         ae.supported_contexts = QueryRetrievePresentationContexts
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_movescu(['-P', '-k', 'PatientName='])
+        p = self.func(['-P', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
@@ -546,7 +555,7 @@ class TestMoveSCU(object):
         ae.supported_contexts = QueryRetrievePresentationContexts
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_movescu(['-S', '-k', 'PatientName='])
+        p = self.func(['-S', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
@@ -577,7 +586,7 @@ class TestMoveSCU(object):
         ae.supported_contexts = QueryRetrievePresentationContexts
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_movescu(['-O', '-k', 'PatientName='])
+        p = self.func(['-O', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
@@ -610,7 +619,7 @@ class TestMoveSCU(object):
         ae.supported_contexts = QueryRetrievePresentationContexts
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_movescu(['--store', '-k', 'PatientName='])
+        p = self.func(['--store', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
@@ -641,7 +650,7 @@ class TestMoveSCU(object):
         ae.supported_contexts = QueryRetrievePresentationContexts
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_movescu(
+        p = self.func(
             ['--store', '--store-port', '11114', '-k', 'PatientName=']
         )
         p.wait()
@@ -679,7 +688,7 @@ class TestMoveSCU(object):
         ae.supported_contexts = QueryRetrievePresentationContexts
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_movescu(
+        p = self.func(
             ['--store', '--store-aet', 'SOMESCP', '-k', 'PatientName=']
         )
         p.wait()
@@ -712,7 +721,7 @@ class TestMoveSCU(object):
 
         assert 'test_dir' not in os.listdir()
 
-        p = start_movescu(['--store', '-od', 'test_dir', '-k', 'PatientName='])
+        p = self.func(['--store', '-od', 'test_dir', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
@@ -741,10 +750,40 @@ class TestMoveSCU(object):
         ae.supported_contexts = QueryRetrievePresentationContexts
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_movescu(['--store', '--ignore', '-k', 'PatientName='])
+        p = self.func(['--store', '--ignore', '-k', 'PatientName='])
         p.wait()
         assert p.returncode == 0
 
         scp.shutdown()
 
         assert 'CT.1.2.3.4' not in os.listdir()
+
+
+class TestMoveSCU(MoveSCUBase):
+    """Tests for movescu.py"""
+    def setup(self):
+        """Run prior to each test"""
+        self.ae = None
+        self.func = start_movescu
+
+        self.response = ds = Dataset()
+        ds.file_meta = Dataset()
+        ds.file_meta.TransferSyntaxUID = ImplicitVRLittleEndian
+        ds.SOPClassUID = CTImageStorage
+        ds.SOPInstanceUID = '1.2.3.4'
+        ds.PatientName = 'Citizen^Jan'
+
+
+class TestMoveSCUCLI(MoveSCUBase):
+    """Tests for movescu.py"""
+    def setup(self):
+        """Run prior to each test"""
+        self.ae = None
+        self.func = start_movescu_cli
+
+        self.response = ds = Dataset()
+        ds.file_meta = Dataset()
+        ds.file_meta.TransferSyntaxUID = ImplicitVRLittleEndian
+        ds.SOPClassUID = CTImageStorage
+        ds.SOPInstanceUID = '1.2.3.4'
+        ds.PatientName = 'Citizen^Jan'
diff --git a/pynetdicom/apps/tests/test_storescp.py b/pynetdicom/apps/tests/test_storescp.py
index 2c7adc1a7..e2c0a79ee 100644
--- a/pynetdicom/apps/tests/test_storescp.py
+++ b/pynetdicom/apps/tests/test_storescp.py
@@ -51,12 +51,21 @@ def start_storescp(args):
     return subprocess.Popen(pargs)
 
 
-class TestStoreSCP(object):
+def start_storescp_cli(args):
+    """Start the storescp app using CLI and return the process."""
+    pargs = [
+        which('python'), '-m', 'pynetdicom', 'storescp', '11112'
+    ] + [*args]
+    return subprocess.Popen(pargs)
+
+
+class StoreSCPBase(object):
     """Tests for storescp.py"""
     def setup(self):
         """Run prior to each test"""
         self.ae = None
         self.p = None
+        self.func = None
 
     def teardown(self):
         """Clear any active threads"""
@@ -76,7 +85,7 @@ class TestStoreSCP(object):
         ae.add_requested_context(VerificationSOPClass)
         ae.add_requested_context(CTImageStorage)
 
-        self.p = p = start_storescp([])
+        self.p = p = self.func([])
         time.sleep(0.5)
 
         assoc = ae.associate('localhost', 11112)
@@ -96,7 +105,7 @@ class TestStoreSCP(object):
 
     def test_flag_version(self, capfd):
         """Test --version flag."""
-        self.p = p = start_storescp(['--version'])
+        self.p = p = self.func(['--version'])
         p.wait()
         assert p.returncode == 0
 
@@ -112,7 +121,7 @@ class TestStoreSCP(object):
         ae.add_requested_context(VerificationSOPClass)
         ae.add_requested_context(CTImageStorage)
 
-        self.p = p = start_storescp(['-q'])
+        self.p = p = self.func(['-q'])
         time.sleep(0.5)
 
         assoc = ae.associate('localhost', 11112)
@@ -136,7 +145,7 @@ class TestStoreSCP(object):
         ae.add_requested_context(VerificationSOPClass)
         ae.add_requested_context(CTImageStorage)
 
-        self.p = p = start_storescp(['-v'])
+        self.p = p = self.func(['-v'])
         time.sleep(0.5)
 
         assoc = ae.associate('localhost', 11112)
@@ -162,7 +171,7 @@ class TestStoreSCP(object):
         ae.add_requested_context(VerificationSOPClass)
         ae.add_requested_context(CTImageStorage)
 
-        self.p = p = start_storescp(['-d'])
+        self.p = p = self.func(['-d'])
         time.sleep(0.5)
 
         assoc = ae.associate('localhost', 11112)
@@ -180,7 +189,7 @@ class TestStoreSCP(object):
 
     def test_flag_log_collision(self):
         """Test error with -q -v and -d flag."""
-        self.p = p = start_storescp(['-v', '-d'])
+        self.p = p = self.func(['-v', '-d'])
         p.wait()
         assert p.returncode != 0
 
@@ -212,7 +221,7 @@ class TestStoreSCP(object):
         ae.network_timeout = 5
         ae.add_requested_context(VerificationSOPClass)
 
-        self.p = p = start_storescp(['--max-pdu', '123456'])
+        self.p = p = self.func(['--max-pdu', '123456'])
         time.sleep(0.5)
 
         assoc = ae.associate('localhost', 11112)
@@ -232,7 +241,7 @@ class TestStoreSCP(object):
         ae.network_timeout = 5
         ae.add_requested_context(VerificationSOPClass)
 
-        self.p = p = start_storescp(['-x='])
+        self.p = p = self.func(['-x='])
         time.sleep(0.5)
 
         assoc = ae.associate('localhost', 11112)
@@ -253,7 +262,7 @@ class TestStoreSCP(object):
         ae.network_timeout = 5
         ae.add_requested_context(VerificationSOPClass)
 
-        self.p = p = start_storescp(['-xe'])
+        self.p = p = self.func(['-xe'])
         time.sleep(0.5)
 
         assoc = ae.associate('localhost', 11112)
@@ -274,7 +283,7 @@ class TestStoreSCP(object):
         ae.network_timeout = 5
         ae.add_requested_context(VerificationSOPClass)
 
-        self.p = p = start_storescp(['-xb'])
+        self.p = p = self.func(['-xb'])
         time.sleep(0.5)
 
         assoc = ae.associate('localhost', 11112)
@@ -295,7 +304,7 @@ class TestStoreSCP(object):
         ae.network_timeout = 5
         ae.add_requested_context(VerificationSOPClass)
 
-        self.p = p = start_storescp(['-xi'])
+        self.p = p = self.func(['-xi'])
         time.sleep(0.5)
 
         assoc = ae.associate('localhost', 11112)
@@ -319,7 +328,7 @@ class TestStoreSCP(object):
 
         assert 'test_dir' not in os.listdir()
 
-        self.p = p = start_storescp(['-od', 'test_dir'])
+        self.p = p = self.func(['-od', 'test_dir'])
         time.sleep(0.5)
 
         ds = dcmread(DATASET_FILE)
@@ -343,7 +352,7 @@ class TestStoreSCP(object):
         ae.add_requested_context(VerificationSOPClass)
         ae.add_requested_context(CTImageStorage)
 
-        self.p = p = start_storescp(['--ignore'])
+        self.p = p = self.func(['--ignore'])
         time.sleep(0.5)
 
         ds = dcmread(DATASET_FILE)
@@ -355,3 +364,21 @@ class TestStoreSCP(object):
         assoc.release()
 
         assert 'CT.{}'.format(ds.SOPInstanceUID) not in os.listdir()
+
+
+class TestStoreSCP(StoreSCPBase):
+    """Tests for storescp.py"""
+    def setup(self):
+        """Run prior to each test"""
+        self.ae = None
+        self.p = None
+        self.func = start_storescp
+
+
+class TestStoreSCPCLI(StoreSCPBase):
+    """Tests for storescp using CLI"""
+    def setup(self):
+        """Run prior to each test"""
+        self.ae = None
+        self.p = None
+        self.func = start_storescp_cli
diff --git a/pynetdicom/apps/tests/test_storescu.py b/pynetdicom/apps/tests/test_storescu.py
index 0e9e8e56d..fdbd4dec7 100644
--- a/pynetdicom/apps/tests/test_storescu.py
+++ b/pynetdicom/apps/tests/test_storescu.py
@@ -54,11 +54,20 @@ def start_storescu(args):
     return subprocess.Popen(pargs)
 
 
-class TestStoreSCU(object):
+def start_storescu_cli(args):
+    """Start the storescu app using CLI and return the process."""
+    pargs = [
+        which('python'), '-m', 'pynetdicom', 'storescu', 'localhost', '11112'
+    ] + [*args]
+    return subprocess.Popen(pargs)
+
+
+class StoreSCUBase(object):
     """Tests for storescu.py"""
     def setup(self):
         """Run prior to each test"""
         self.ae = None
+        self.func = None
 
     def teardown(self):
         """Clear any active threads"""
@@ -88,7 +97,7 @@ class TestStoreSCU(object):
         ae.add_supported_context(CTImageStorage)
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_storescu([DATASET_FILE])
+        p = self.func([DATASET_FILE])
         p.wait()
         assert p.returncode == 0
 
@@ -119,7 +128,7 @@ class TestStoreSCU(object):
 
     def test_no_peer(self, capfd):
         """Test trying to connect to non-existent host."""
-        p = start_storescu([DATASET_FILE])
+        p = self.func([DATASET_FILE])
         p.wait()
         assert p.returncode == 1
 
@@ -130,7 +139,7 @@ class TestStoreSCU(object):
 
     def test_flag_version(self, capfd):
         """Test --version flag."""
-        p = start_storescu([DATASET_FILE, '--version'])
+        p = self.func([DATASET_FILE, '--version'])
         p.wait()
         assert p.returncode == 0
 
@@ -146,7 +155,7 @@ class TestStoreSCU(object):
         ae.add_supported_context(VerificationSOPClass)
         scp = ae.start_server(('', 11112), block=False)
 
-        p = start_storescu([DATASET_FILE, '-q'])
+        p = self.func([DATASET_FILE, '-q'])
         p.wait()
         assert p.returncode == 1
 
@@ -171,7 +180,7 @@ class TestStoreSCU(object):
         ae.add_supported_context(CTImageStorage)
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_storescu([DATASET_FILE, '-v'])
+        p = self.func([DATASET_FILE, '-v'])
         p.wait()
         assert p.returncode == 0
 
@@ -201,7 +210,7 @@ class TestStoreSCU(object):
         ae.add_supported_context(CTImageStorage)
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_storescu([DATASET_FILE, '-d'])
+        p = self.func([DATASET_FILE, '-d'])
         p.wait()
         assert p.returncode == 0
 
@@ -213,7 +222,7 @@ class TestStoreSCU(object):
 
     def test_flag_log_collision(self):
         """Test error with -q -v and -d flag."""
-        p = start_storescu([DATASET_FILE, '-v', '-d'])
+        p = self.func([DATASET_FILE, '-v', '-d'])
         p.wait()
         assert p.returncode != 0
 
@@ -240,7 +249,7 @@ class TestStoreSCU(object):
         ae.add_supported_context(CTImageStorage)
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_storescu([DATASET_FILE, '-aet', 'MYSCU'])
+        p = self.func([DATASET_FILE, '-aet', 'MYSCU'])
         p.wait()
         assert p.returncode == 0
 
@@ -268,7 +277,7 @@ class TestStoreSCU(object):
         ae.add_supported_context(CTImageStorage)
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_storescu([DATASET_FILE, '-aec', 'YOURSCP'])
+        p = self.func([DATASET_FILE, '-aec', 'YOURSCP'])
         p.wait()
         assert p.returncode == 0
 
@@ -306,7 +315,7 @@ class TestStoreSCU(object):
         ae.add_supported_context(CTImageStorage)
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_storescu([DATASET_FILE, '-ta', '0.05', '-d'])
+        p = self.func([DATASET_FILE, '-ta', '0.05', '-d'])
         p.wait()
         assert p.returncode == 1
 
@@ -343,7 +352,7 @@ class TestStoreSCU(object):
         ae.add_supported_context(CTImageStorage)
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_storescu([DATASET_FILE, '-td', '0.05', '-d'])
+        p = self.func([DATASET_FILE, '-td', '0.05', '-d'])
         p.wait()
         assert p.returncode == 0
 
@@ -384,7 +393,7 @@ class TestStoreSCU(object):
         ae.add_supported_context(CTImageStorage)
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_storescu([DATASET_FILE, '--max-pdu', '123456'])
+        p = self.func([DATASET_FILE, '--max-pdu', '123456'])
         p.wait()
         assert p.returncode == 0
 
@@ -418,7 +427,7 @@ class TestStoreSCU(object):
         ae.add_supported_context(CTImageStorage)
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_storescu([DATASET_FILE, '-xe'])
+        p = self.func([DATASET_FILE, '-xe'])
         p.wait()
         assert p.returncode == 0
 
@@ -457,7 +466,7 @@ class TestStoreSCU(object):
         ae.add_supported_context(CTImageStorage)
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_storescu([DATASET_FILE, '-xb'])
+        p = self.func([DATASET_FILE, '-xb'])
         p.wait()
         assert p.returncode == 0
 
@@ -497,7 +506,7 @@ class TestStoreSCU(object):
         ae.add_supported_context(CTImageStorage)
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_storescu([DATASET_FILE, '-xi'])
+        p = self.func([DATASET_FILE, '-xi'])
         p.wait()
         assert p.returncode == 0
 
@@ -537,7 +546,7 @@ class TestStoreSCU(object):
         ae.add_supported_context(CTImageStorage)
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_storescu([DATASET_FILE, '-cx'])
+        p = self.func([DATASET_FILE, '-cx'])
         p.wait()
         assert p.returncode == 0
 
@@ -553,7 +562,7 @@ class TestStoreSCU(object):
 
     def test_bad_input(self, capfd):
         """Test being unable to read the input file."""
-        p = start_storescu(['no-such-file.dcm', '-d'])
+        p = self.func(['no-such-file.dcm', '-d'])
         p.wait()
         assert p.returncode == 0
 
@@ -580,10 +589,26 @@ class TestStoreSCU(object):
             ae.add_supported_context(cx.abstract_syntax, ALL_TRANSFER_SYNTAXES)
         scp = ae.start_server(('', 11112), block=False, evt_handlers=handlers)
 
-        p = start_storescu([LIB_DIR, '--recurse', '-cx'])
+        p = self.func([LIB_DIR, '--recurse', '-cx'])
         p.wait()
         assert p.returncode == 0
 
         scp.shutdown()
 
         assert len(events) == 3
+
+
+class TestStoreSCU(StoreSCUBase):
+    """Tests for storescu.py"""
+    def setup(self):
+        """Run prior to each test"""
+        self.ae = None
+        self.func = start_storescu
+
+
+class TestStoreSCUCLI(StoreSCUBase):
+    """Tests for storescu using CLI"""
+    def setup(self):
+        """Run prior to each test"""
+        self.ae = None
+        self.func = start_storescu_cli
diff --git a/pynetdicom/tests/test_cli.py b/pynetdicom/tests/test_cli.py
new file mode 100644
index 000000000..5f5b60ad8
--- /dev/null
+++ b/pynetdicom/tests/test_cli.py
@@ -0,0 +1,24 @@
+"""Tests for the command-line interface."""
+
+import subprocess
+
+import pytest
+
+from pynetdicom import __version__
+
+
+def test_version():
+    """Test --version."""
+    command = ['python', '-m', 'pynetdicom', '--version']
+    out = subprocess.check_output(command)
+    assert __version__ == out.decode('utf-8').strip()
+
+
+def test_echoscu():
+    """Test echoscu."""
+    command = ['python', '-m', 'pynetdicom', 'echoscu', 'localhost', '11112']
+    p = subprocess.Popen(command, stderr=subprocess.PIPE)
+    p.wait()
+    assert p.returncode == 1
+    out, err = p.communicate()
+    assert "unable to connect to remote" in err.decode('utf-8')