Index: .travis.yml
===================================================================
--- .travis.yml	(revision 2d09df1a016d5aba6f689a4b863e9e4314756ac0)
+++ .travis.yml	(revision 335271e7aa66a7275b4a987d7b763a60ff47493b)
@@ -37,11 +37,13 @@
 - conda update --yes conda
 - conda info -a
-- conda install --yes python=$PY numpy scipy cython mako cffi
+- conda install --yes python=$PY numpy scipy matplotlib docutils setuptools pytest
 install:
 - pip install bumps
-- pip install unittest-xml-reporting
+- pip install unittest-xml-reporting tinycc
 script:
 - python --version
-- python -m sasmodels.model_test -v dll all
+#- python -m sasmodels.model_test -v dll all
+#- python -m pytest -v --cache-clear
+- python setup.py test --pytest-args -v
 before_deploy:
 - echo -e "Host danse.chem.utk.edu\n\tStrictHostKeyChecking no\n" >> ~/.ssh/config
Index: appveyor.yml
===================================================================
--- appveyor.yml	(revision fefc185ba4c27aefa57a3fb46c7866f0a7a0d4c9)
+++ appveyor.yml	(revision 335271e7aa66a7275b4a987d7b763a60ff47493b)
@@ -44,5 +44,5 @@
 
 install:
-    # Set the CONDA_NPY, although it has no impact on the actual build. 
+    # Set the CONDA_NPY, although it has no impact on the actual build.
     # We need this because of a test within conda-build.
     - cmd: set CONDA_NPY=19
@@ -68,5 +68,5 @@
     #- cmd: conda install --yes --quiet obvious-ci
     # 2018-01-19 PAK: skipping toolchain cython and cffi (these were for pyopencl?)
-    - cmd: conda install --yes --quiet numpy scipy
+    - cmd: conda install --yes --quiet numpy scipy matplotlib docutils setuptools pytest
     # 2018-01-19 PAK: skipping pyopencl; this would be needed for deploy but maybe not for test
     #- cmd: conda install --yes --channel conda-forge pyopencl
@@ -86,3 +86,6 @@
     #- "%CMD_IN_ENV% python -m sasmodels.model_test dll all"
     #- cmd /E:ON /V:ON /C obvci_appveyor_python_build_env.cmd python -m sasmoels.model_test dll all
-    - python -m sasmodels.model_test dll all
+    #- python -m sasmodels.model_test dll all
+    #- nosetests -v sasmodels/*.py
+    #- python -m pytest -v --cache-clear
+    - python setup.py test --pytest-args -v
Index: conftest.py
===================================================================
--- conftest.py	(revision f6fd413b32398bc181650cdaf712d5f2981dfc85)
+++ conftest.py	(revision f6fd413b32398bc181650cdaf712d5f2981dfc85)
@@ -0,0 +1,119 @@
+"""
+pytest hooks for sasmodels
+
+Hooks for running sasmodels tests via pytest.
+
+*pytest_collection_modifyitems* adds the test description to the end of
+the test name.  This is needed for the generated list of tests is sasmodels,
+where each test has a description giving the name of the model.  For example
+"model_tests::[3]" becomes "model_tests::[3]::bcc_paracrystal-dll".  Need to
+leave the "::[3]" in the name since that is the way you can indicate this
+test specifically from the pytest command line. [This is perhaps because
+the modifyitems hook is only called after test selection.]
+
+*pytest_ignore_collect* skips kernelcl.py if pyopencl cannot be imported.
+"""
+from __future__ import print_function
+
+import os.path
+import inspect
+
+import pytest
+from _pytest.unittest import TestCaseFunction
+
+def pytest_pycollect_makeitem(collector, name, obj):
+    """
+    Convert test generator into list of function tests so that pytest doesn't
+    complain about deprecated yield tests.
+
+    Note that unlike nose, the tests are generated and saved instead of run
+    immediately.  This means that any dynamic context, such as a for-loop
+    variable, must be captured by wrapping the yield result in a function call.
+
+    For example::
+
+        for value in 1, 2, 3:
+            for test in test_cases:
+                yield test, value
+
+    will need to be changed to::
+
+        def build_test(test, value):
+            return test, value
+        for value in 1, 2, 3:
+            for test in test_cases:
+                yield build_test(test, value)
+
+    This allows the context (test and value) to be captured by lexical closure
+    in build_test. See https://stackoverflow.com/a/233835/6195051.
+    """
+    if collector.istestfunction(obj, name) and is_generator(obj):
+        tests = []
+        for number, yielded in enumerate(obj()):
+            index, call, args = split_yielded_test(yielded, number)
+            test = pytest.Function(name+index, collector, args=args, callobj=call)
+            tests.append(test)
+        return tests
+
+def is_generator(func):
+    """
+    Returns True if function has yield.
+    """
+    # Cribbed from _pytest.compat is_generator and iscoroutinefunction; these
+    # may not be available as part of pytest 4.
+    coroutine = (getattr(func, '_is_coroutine', False) or
+                 getattr(inspect, 'iscoroutinefunction', lambda f: False)(func))
+    generator = inspect.isgeneratorfunction(func)
+    return generator and not coroutine
+
+def split_yielded_test(obj, number):
+    if not isinstance(obj, (tuple, list)):
+        obj = (obj,)
+    if not callable(obj[0]):
+        index = "['%s']"%obj[0]
+        obj = obj[1:]
+    else:
+        index = "[%d]"%number
+    call, args = obj[0], obj[1:]
+    return index, call, args
+
+USE_DOCSTRING_AS_DESCRIPTION = True
+def pytest_collection_modifyitems(session, config, items):
+    """
+    Add description to the test node id if item is a function and function
+    has a description attribute or __doc__ attribute.
+    """
+    for item in items:
+        if isinstance(item, pytest.Function):
+            if isinstance(item, TestCaseFunction):
+                # TestCase uses item.name to find the method so skip
+                continue
+            function = item.obj
+
+            # If the test case provides a "description" attribute then use it
+            # as an extended description.  If there is no description attribute,
+            # then perhaps use the test docstring.
+            if USE_DOCSTRING_AS_DESCRIPTION:
+                description = getattr(function, 'description', function.__doc__)
+            else:
+                description = getattr(function, 'description', "")
+
+            # If description is not supplied but yield args are, then use the
+            # yield args for the description
+            if not description and getattr(item, '_args', ()):
+                description = (str(item._args) if len(item._args) > 1
+                               else str(item._args[0]))
+
+            # Set the description as part of the node identifier.
+            if description:
+                # Strip spaces from start and end and strip dots from end
+                # pytest converts '.' to '::' on output for some reason.
+                description = description.strip().rstrip('.')
+                # Join multi-line descriptions into a single line
+                if '\n' in description:
+                    description = " ".join(line.strip()
+                                           for line in description.split('\n'))
+
+                # Note: leave the current name mostly as-is since the prefix
+                # is needed to specify the nth test from a list of tests.
+                item.name += "::" + description
Index: pytest.ini
===================================================================
--- pytest.ini	(revision a69d8cd7ece8a1df933b123d2e032197783d80b4)
+++ pytest.ini	(revision a69d8cd7ece8a1df933b123d2e032197783d80b4)
@@ -0,0 +1,7 @@
+[pytest]
+addopts = --doctest-modules
+doctest_optionflags = ELLIPSIS
+testpaths = sasmodels
+python_files = *.py
+python_classes = NoClassTestsWillMatch
+python_functions = *_test test_* model_tests
Index: sasmodels/compare.py
===================================================================
--- sasmodels/compare.py	(revision 2a7e20e9c4ba72da5c6088e743a64f3345ee554d)
+++ sasmodels/compare.py	(revision 3221de0ed9b3480163246ceb0503432126bd5134)
@@ -40,4 +40,5 @@
 from . import core
 from . import kerneldll
+from . import kernelcl
 from .data import plot_theory, empty_data1D, empty_data2D, load_data
 from .direct_model import DirectModel, get_mesh
@@ -623,4 +624,51 @@
 
 
+def time_calculation(calculator, pars, evals=1):
+    # type: (Calculator, ParameterSet, int) -> Tuple[np.ndarray, float]
+    """
+    Compute the average calculation time over N evaluations.
+
+    An additional call is generated without polydispersity in order to
+    initialize the calculation engine, and make the average more stable.
+    """
+    # initialize the code so time is more accurate
+    if evals > 1:
+        calculator(**suppress_pd(pars))
+    toc = tic()
+    # make sure there is at least one eval
+    value = calculator(**pars)
+    for _ in range(evals-1):
+        value = calculator(**pars)
+    average_time = toc()*1000. / evals
+    #print("I(q)",value)
+    return value, average_time
+
+def make_data(opts):
+    # type: (Dict[str, Any]) -> Tuple[Data, np.ndarray]
+    """
+    Generate an empty dataset, used with the model to set Q points
+    and resolution.
+
+    *opts* contains the options, with 'qmax', 'nq', 'res',
+    'accuracy', 'is2d' and 'view' parsed from the command line.
+    """
+    qmin, qmax, nq, res = opts['qmin'], opts['qmax'], opts['nq'], opts['res']
+    if opts['is2d']:
+        q = np.linspace(-qmax, qmax, nq)  # type: np.ndarray
+        data = empty_data2D(q, resolution=res)
+        data.accuracy = opts['accuracy']
+        set_beam_stop(data, qmin)
+        index = ~data.mask
+    else:
+        if opts['view'] == 'log' and not opts['zero']:
+            q = np.logspace(math.log10(qmin), math.log10(qmax), nq)
+        else:
+            q = np.linspace(qmin, qmax, nq)
+        if opts['zero']:
+            q = np.hstack((0, q))
+        data = empty_data1D(q, resolution=res)
+        index = slice(None, None)
+    return data, index
+
 DTYPE_MAP = {
     'half': '16',
@@ -643,10 +691,4 @@
     Return a model calculator using the OpenCL calculation engine.
     """
-    if not core.HAVE_OPENCL:
-        raise RuntimeError("OpenCL not available")
-    model = core.build_model(model_info, dtype=dtype, platform="ocl")
-    calculator = DirectModel(data, model, cutoff=cutoff)
-    calculator.engine = "OCL%s"%DTYPE_MAP[str(model.dtype)]
-    return calculator
 
 def eval_ctypes(model_info, data, dtype='double', cutoff=0.):
@@ -660,51 +702,4 @@
     return calculator
 
-def time_calculation(calculator, pars, evals=1):
-    # type: (Calculator, ParameterSet, int) -> Tuple[np.ndarray, float]
-    """
-    Compute the average calculation time over N evaluations.
-
-    An additional call is generated without polydispersity in order to
-    initialize the calculation engine, and make the average more stable.
-    """
-    # initialize the code so time is more accurate
-    if evals > 1:
-        calculator(**suppress_pd(pars))
-    toc = tic()
-    # make sure there is at least one eval
-    value = calculator(**pars)
-    for _ in range(evals-1):
-        value = calculator(**pars)
-    average_time = toc()*1000. / evals
-    #print("I(q)",value)
-    return value, average_time
-
-def make_data(opts):
-    # type: (Dict[str, Any]) -> Tuple[Data, np.ndarray]
-    """
-    Generate an empty dataset, used with the model to set Q points
-    and resolution.
-
-    *opts* contains the options, with 'qmax', 'nq', 'res',
-    'accuracy', 'is2d' and 'view' parsed from the command line.
-    """
-    qmin, qmax, nq, res = opts['qmin'], opts['qmax'], opts['nq'], opts['res']
-    if opts['is2d']:
-        q = np.linspace(-qmax, qmax, nq)  # type: np.ndarray
-        data = empty_data2D(q, resolution=res)
-        data.accuracy = opts['accuracy']
-        set_beam_stop(data, qmin)
-        index = ~data.mask
-    else:
-        if opts['view'] == 'log' and not opts['zero']:
-            q = np.logspace(math.log10(qmin), math.log10(qmax), nq)
-        else:
-            q = np.linspace(qmin, qmax, nq)
-        if opts['zero']:
-            q = np.hstack((0, q))
-        data = empty_data1D(q, resolution=res)
-        index = slice(None, None)
-    return data, index
-
 def make_engine(model_info, data, dtype, cutoff, ngauss=0):
     # type: (ModelInfo, Data, str, float) -> Calculator
@@ -718,8 +713,14 @@
         set_integration_size(model_info, ngauss)
 
-    if dtype is None or not dtype.endswith('!'):
-        return eval_opencl(model_info, data, dtype=dtype, cutoff=cutoff)
-    else:
-        return eval_ctypes(model_info, data, dtype=dtype[:-1], cutoff=cutoff)
+    if dtype != "default" and not dtype.endswith('!') and not kernelcl.use_opencl():
+        raise RuntimeError("OpenCL not available " + kernelcl.OPENCL_ERROR)
+
+    model = core.build_model(model_info, dtype=dtype, platform="ocl")
+    calculator = DirectModel(data, model, cutoff=cutoff)
+    engine_type = calculator._model.__class__.__name__.replace('Model','').upper()
+    bits = calculator._model.dtype.itemsize*8
+    precision = "fast" if getattr(calculator._model, 'fast', False) else str(bits)
+    calculator.engine = "%s[%s]" % (engine_type, precision)
+    return calculator
 
 def _show_invalid(data, theory):
Index: sasmodels/convert.py
===================================================================
--- sasmodels/convert.py	(revision 2d81cfe3be42579d563407fb9275b7b516fee234)
+++ sasmodels/convert.py	(revision a69d8cd7ece8a1df933b123d2e032197783d80b4)
@@ -633,6 +633,5 @@
 def test_backward_forward():
     from .core import list_models
+    L = lambda name: _check_one(name, seed=1)
     for name in list_models('all'):
-        L = lambda: _check_one(name, seed=1)
-        L.description = name
-        yield L
+        yield L, name
Index: sasmodels/core.py
===================================================================
--- sasmodels/core.py	(revision 7a516d0ce65f7da1dba6a83326e7d52182461976)
+++ sasmodels/core.py	(revision 3221de0ed9b3480163246ceb0503432126bd5134)
@@ -21,21 +21,7 @@
 from . import mixture
 from . import kernelpy
+from . import kernelcl
 from . import kerneldll
 from . import custom
-
-if os.environ.get("SAS_OPENCL", "").lower() == "none":
-    HAVE_OPENCL = False
-else:
-    try:
-        from . import kernelcl
-        HAVE_OPENCL = True
-    except Exception:
-        HAVE_OPENCL = False
-
-CUSTOM_MODEL_PATH = os.environ.get('SAS_MODELPATH', "")
-if CUSTOM_MODEL_PATH == "":
-    CUSTOM_MODEL_PATH = joinpath(os.path.expanduser("~"), ".sasmodels", "custom_models")
-    if not os.path.isdir(CUSTOM_MODEL_PATH):
-        os.makedirs(CUSTOM_MODEL_PATH)
 
 # pylint: disable=unused-import
@@ -47,4 +33,10 @@
     pass
 # pylint: enable=unused-import
+
+CUSTOM_MODEL_PATH = os.environ.get('SAS_MODELPATH', "")
+if CUSTOM_MODEL_PATH == "":
+    CUSTOM_MODEL_PATH = joinpath(os.path.expanduser("~"), ".sasmodels", "custom_models")
+    if not os.path.isdir(CUSTOM_MODEL_PATH):
+        os.makedirs(CUSTOM_MODEL_PATH)
 
 # TODO: refactor composite model support
@@ -276,5 +268,5 @@
     if platform is None:
         platform = "ocl"
-    if platform == "ocl" and not HAVE_OPENCL or not model_info.opencl:
+    if not kernelcl.use_opencl() or not model_info.opencl:
         platform = "dll"
 
@@ -320,47 +312,54 @@
     print("\n".join(list_models(kind)))
 
-def test_load():
-    # type: () -> None
-    """Check that model load works"""
+def test_composite_order():
     def test_models(fst, snd):
         """Confirm that two models produce the same parameters"""
         fst = load_model(fst)
         snd = load_model(snd)
-        # Remove the upper case characters frin the parameters, since
-        # they lasndel the order of events and we specfically are
-        # changin that aspect
+        # Un-disambiguate parameter names so that we can check if the same
+        # parameters are in a pair of composite models. Since each parameter in
+        # the mixture model is tagged as e.g., A_sld, we ought to use a
+        # regex subsitution s/^[A-Z]+_/_/, but removing all uppercase letters
+        # is good enough.
         fst = [[x for x in p.name if x == x.lower()] for p in fst.info.parameters.kernel_parameters]
         snd = [[x for x in p.name if x == x.lower()] for p in snd.info.parameters.kernel_parameters]
         assert sorted(fst) == sorted(snd), "{} != {}".format(fst, snd)
 
-
-    test_models(
+    def build_test(first, second):
+        test = lambda description: test_models(first, second)
+        description = first + " vs. " + second
+        return test, description
+
+    yield build_test(
         "cylinder+sphere",
         "sphere+cylinder")
-    test_models(
+    yield build_test(
         "cylinder*sphere",
         "sphere*cylinder")
-    test_models(
+    yield build_test(
         "cylinder@hardsphere*sphere",
         "sphere*cylinder@hardsphere")
-    test_models(
+    yield build_test(
         "barbell+sphere*cylinder@hardsphere",
         "sphere*cylinder@hardsphere+barbell")
-    test_models(
+    yield build_test(
         "barbell+cylinder@hardsphere*sphere",
         "cylinder@hardsphere*sphere+barbell")
-    test_models(
+    yield build_test(
         "barbell+sphere*cylinder@hardsphere",
         "barbell+cylinder@hardsphere*sphere")
-    test_models(
+    yield build_test(
         "sphere*cylinder@hardsphere+barbell",
         "cylinder@hardsphere*sphere+barbell")
-    test_models(
+    yield build_test(
         "barbell+sphere*cylinder@hardsphere",
         "cylinder@hardsphere*sphere+barbell")
-    test_models(
+    yield build_test(
         "barbell+cylinder@hardsphere*sphere",
         "sphere*cylinder@hardsphere+barbell")
 
+def test_composite():
+    # type: () -> None
+    """Check that model load works"""
     #Test the the model produces the parameters that we would expect
     model = load_model("cylinder@hardsphere*sphere")
Index: sasmodels/jitter.py
===================================================================
--- sasmodels/jitter.py	(revision 0d5a655d6ddebe6096fad050347df4796b956767)
+++ sasmodels/jitter.py	(revision 42158d29f42caa923baa8d81e6eb6518523961a4)
@@ -13,5 +13,9 @@
 import argparse
 
-import mpl_toolkits.mplot3d   # Adds projection='3d' option to subplot
+try: # CRUFT: travis-ci does not support mpl_toolkits.mplot3d
+    import mpl_toolkits.mplot3d  # Adds projection='3d' option to subplot
+except ImportError:
+    pass
+
 import matplotlib.pyplot as plt
 from matplotlib.widgets import Slider, CheckButtons
Index: sasmodels/kernelcl.py
===================================================================
--- sasmodels/kernelcl.py	(revision 2d81cfe3be42579d563407fb9275b7b516fee234)
+++ sasmodels/kernelcl.py	(revision b4272a28f7438eece0027fe632a01824b6da7f98)
@@ -58,16 +58,18 @@
 import numpy as np  # type: ignore
 
+
+# Attempt to setup opencl. This may fail if the opencl package is not
+# installed or if it is installed but there are no devices available.
 try:
-    #raise NotImplementedError("OpenCL not yet implemented for new kernel template")
     import pyopencl as cl  # type: ignore
+    from pyopencl import mem_flags as mf
+    from pyopencl.characterize import get_fast_inaccurate_build_options
     # Ask OpenCL for the default context so that we know that one exists
     cl.create_some_context(interactive=False)
+    HAVE_OPENCL = True
+    OPENCL_ERROR = ""
 except Exception as exc:
-    warnings.warn("OpenCL startup failed with ***"
-                  + str(exc) + "***; using C compiler instead")
-    raise RuntimeError("OpenCL not available")
-
-from pyopencl import mem_flags as mf
-from pyopencl.characterize import get_fast_inaccurate_build_options
+    HAVE_OPENCL = False
+    OPENCL_ERROR = str(exc)
 
 from . import generate
@@ -102,6 +104,6 @@
         cl._DEFAULT_INCLUDE_OPTIONS = [quote_path(v) for v in cl._DEFAULT_INCLUDE_OPTIONS]
 
-fix_pyopencl_include()
-
+if HAVE_OPENCL:
+    fix_pyopencl_include()
 
 # The max loops number is limited by the amount of local memory available
@@ -128,6 +130,15 @@
 """
 
+def use_opencl():
+    return HAVE_OPENCL and os.environ.get("SAS_OPENCL", "").lower() != "none"
 
 ENV = None
+def reset_environment():
+    """
+    Call to create a new OpenCL context, such as after a change to SAS_OPENCL.
+    """
+    global ENV
+    ENV = GpuEnvironment() if use_opencl() else None
+
 def environment():
     # type: () -> "GpuEnvironment"
@@ -137,7 +148,11 @@
     This provides an OpenCL context and one queue per device.
     """
-    global ENV
     if ENV is None:
-        ENV = GpuEnvironment()
+        if not HAVE_OPENCL:
+            raise RuntimeError("OpenCL startup failed with ***"
+                            + OPENCL_ERROR + "***; using C compiler instead")
+        reset_environment()
+        if ENV is None:
+            raise RuntimeError("SAS_OPENCL=None in environment")
     return ENV
 
Index: sasmodels/model_test.py
===================================================================
--- sasmodels/model_test.py	(revision 2d81cfe3be42579d563407fb9275b7b516fee234)
+++ sasmodels/model_test.py	(revision 3221de0ed9b3480163246ceb0503432126bd5134)
@@ -62,4 +62,5 @@
 from .exception import annotate_exception
 from .modelinfo import expand_pars
+from .kernelcl import use_opencl
 
 # pylint: disable=unused-import
@@ -123,5 +124,5 @@
 
         if is_py:  # kernel implemented in python
-            test_name = "Model: %s, Kernel: python"%model_name
+            test_name = "%s-python"%model_name
             test_method_name = "test_%s_python" % model_info.id
             test = ModelTestCase(test_name, model_info,
@@ -134,6 +135,6 @@
 
             # test using dll if desired
-            if 'dll' in loaders or not core.HAVE_OPENCL:
-                test_name = "Model: %s, Kernel: dll"%model_name
+            if 'dll' in loaders or not use_opencl():
+                test_name = "%s-dll"%model_name
                 test_method_name = "test_%s_dll" % model_info.id
                 test = ModelTestCase(test_name, model_info,
@@ -145,6 +146,6 @@
 
             # test using opencl if desired and available
-            if 'opencl' in loaders and core.HAVE_OPENCL:
-                test_name = "Model: %s, Kernel: OpenCL"%model_name
+            if 'opencl' in loaders and use_opencl():
+                test_name = "%s-opencl"%model_name
                 test_method_name = "test_%s_opencl" % model_info.id
                 # Using dtype=None so that the models that are only
@@ -160,5 +161,4 @@
 
     return suite
-
 
 def _hide_model_case_from_nose():
@@ -368,5 +368,5 @@
 
     # Build a test suite containing just the model
-    loaders = ['opencl'] if core.HAVE_OPENCL else ['dll']
+    loaders = ['opencl'] if use_opencl() else ['dll']
     models = [model]
     try:
@@ -425,5 +425,5 @@
         verbosity = 1
     if models and models[0] == 'opencl':
-        if not core.HAVE_OPENCL:
+        if not use_opencl():
             print("opencl is not available")
             return 1
@@ -435,8 +435,8 @@
         models = models[1:]
     elif models and models[0] == 'opencl_and_dll':
-        loaders = ['opencl', 'dll'] if core.HAVE_OPENCL else ['dll']
+        loaders = ['opencl', 'dll'] if use_opencl() else ['dll']
         models = models[1:]
     else:
-        loaders = ['opencl', 'dll'] if core.HAVE_OPENCL else ['dll']
+        loaders = ['opencl', 'dll'] if use_opencl() else ['dll']
     if not models:
         print("""\
@@ -467,15 +467,25 @@
     Run "nosetests sasmodels" on the command line to invoke it.
     """
-    loaders = ['opencl', 'dll'] if core.HAVE_OPENCL else ['dll']
+    loaders = ['opencl', 'dll'] if use_opencl() else ['dll']
     tests = make_suite(loaders, ['all'])
-    for test_i in tests:
-        # In order for nosetest to see the correct test name, need to set
-        # the description attribute of the returned function.  Since we
-        # can't do this for the returned instance, wrap it in a lambda and
-        # set the description on the lambda.  Otherwise we could just do:
-        #    yield test_i.run_all
-        L = lambda: test_i.run_all()
-        L.description = test_i.test_name
-        yield L
+    def build_test(test):
+        # In order for nosetest to show the test name, wrap the test.run_all
+        # instance in function that takes the test name as a parameter which
+        # will be displayed when the test is run.  Do this as a function so
+        # that it properly captures the context for tests that captured and
+        # run later.  If done directly in the for loop, then the looping
+        # variable test will be shared amongst all the tests, and we will be
+        # repeatedly testing vesicle.
+
+        # Note: in sasview sas.sasgui.perspectives.fitting.gpu_options
+        # requires that the test.description field be set.
+        wrap = lambda: test.run_all()
+        wrap.description = test.test_name
+        return wrap
+        # The following would work with nosetests and pytest:
+        #     return lambda name: test.run_all(), test.test_name
+
+    for test in tests:
+        yield build_test(test)
 
 
Index: sasmodels/sasview_model.py
===================================================================
--- sasmodels/sasview_model.py	(revision 2d81cfe3be42579d563407fb9275b7b516fee234)
+++ sasmodels/sasview_model.py	(revision 3221de0ed9b3480163246ceb0503432126bd5134)
@@ -665,5 +665,4 @@
 
     def _calculate_Iq(self, qx, qy=None):
-        #core.HAVE_OPENCL = False
         if self._model is None:
             self._model = core.build_model(self._model_info)
@@ -859,5 +858,5 @@
     # type: () -> None
     """
-    Load and run cylinder model from sas.models.CylinderModel
+    Load and run cylinder model as sas-models-CylinderModel
     """
     if not SUPPORT_OLD_STYLE_PLUGINS:
Index: setup.py
===================================================================
--- setup.py	(revision 32e3c9bc7952e078b48a3a2ce4bd06b565d7326a)
+++ setup.py	(revision 1f991d6e4007acafaaf7edbc17c549a8b777d374)
@@ -1,6 +1,19 @@
-try:
-    from setuptools import setup
-except ImportError:
-    from distutils.core import setup
+import sys
+from setuptools import setup
+from setuptools.command.test import test as TestCommand
+
+class PyTest(TestCommand):
+    user_options = [('pytest-args=', 'a', "Arguments to pass to pytest")]
+
+    def initialize_options(self):
+        TestCommand.initialize_options(self)
+        self.pytest_args = ''
+
+    def run_tests(self):
+        import shlex
+        #import here, cause outside the eggs aren't loaded
+        import pytest
+        errno = pytest.main(shlex.split(self.pytest_args))
+        sys.exit(errno)
 
 def find_version(package):
@@ -48,10 +61,13 @@
         'sasmodels': ['*.c', '*.cl'],
     },
-    install_requires = [
+    install_requires=[
     ],
-    extras_require = {
+    extras_require={
         'OpenCL': ["pyopencl"],
         'Bumps': ["bumps"],
         'TinyCC': ["tinycc"],
     },
+    build_requires=['setuptools'],
+    test_requires=['pytest'],
+    cmdclass = {'test': PyTest},
 )
