diff --git a/unitgrade2/__pycache__/unitgrade2.cpython-38.pyc b/unitgrade2/__pycache__/unitgrade2.cpython-38.pyc
index a70e7b1190a2294f68b5e051feddd744b6c88a79..3492c9eca22889350f2a5707f0579dc18533a9c0 100644
Binary files a/unitgrade2/__pycache__/unitgrade2.cpython-38.pyc and b/unitgrade2/__pycache__/unitgrade2.cpython-38.pyc differ
diff --git a/unitgrade2/__pycache__/unitgrade_helpers2.cpython-38.pyc b/unitgrade2/__pycache__/unitgrade_helpers2.cpython-38.pyc
index 5f1fcf25687cedd7203740db7a57b848a3ce5503..efd8ce6f58cc6a9f0d3cd6104d4cbe6e9982131d 100644
Binary files a/unitgrade2/__pycache__/unitgrade_helpers2.cpython-38.pyc and b/unitgrade2/__pycache__/unitgrade_helpers2.cpython-38.pyc differ
diff --git a/unitgrade2/unitgrade2.py b/unitgrade2/unitgrade2.py
index 08c9d484a5a65ac3237247dbb929ce78ecdcae5b..c094340817a5b2014b4821b5b306dad9ebfc69ca 100644
--- a/unitgrade2/unitgrade2.py
+++ b/unitgrade2/unitgrade2.py
@@ -45,12 +45,13 @@ class Logger(object):
         pass
 
 class Capturing(list):
-    def __init__(self, *args, unmute=False, **kwargs):
+    def __init__(self, *args, stdout=None, unmute=False, **kwargs):
+        self._stdout = stdout
         self.unmute = unmute
         super().__init__(*args, **kwargs)
 
     def __enter__(self, capture_errors=True): # don't put arguments here.
-        self._stdout = sys.stdout
+        self._stdout = sys.stdout if self._stdout == None else self._stdout
         self._stringio = StringIO()
         if self.unmute:
             sys.stdout = Logger(self._stringio)
@@ -70,6 +71,20 @@ class Capturing(list):
         if self.capture_errors:
             sys.sterr = self._sterr
 
+class Capturing2(Capturing):
+    def __exit__(self, *args):
+        lines = self._stringio.getvalue().splitlines()
+        txt = "\n".join(lines)
+        numbers = extract_numbers(txt)
+        self.extend(lines)
+        del self._stringio    # free up some memory
+        sys.stdout = self._stdout
+        if self.capture_errors:
+            sys.sterr = self._sterr
+
+        self.output = txt
+        self.numbers = numbers
+
 
 class QItem(unittest.TestCase):
     title = None
@@ -313,12 +328,13 @@ class Report():
         modules = os.path.normpath(relative_path[:-3]).split(os.sep)
         return root_dir, relative_path, modules
 
-
     def __init__(self, strict=False, payload=None):
         working_directory = os.path.abspath(os.path.dirname(self._file()))
 
         self.wdir, self.name = setup_dir_by_class(self, working_directory)
         # self.computed_answers_file = os.path.join(self.wdir, self.name + "_resources_do_not_hand_in.dat")
+        for (q,_) in self.questions:
+            q.nL = self.nL # Set maximum line length.
 
         if payload is not None:
             self.set_payload(payload, strict=strict)
@@ -360,28 +376,6 @@ class Report():
         for q, _ in self.questions:
             q._cache = payloads[q.__qualname__]
 
-            # for item in q.items:
-            #     if q.name not in payloads or item.name not in payloads[q.name]:
-            #         s = f"> Broken resource dictionary submitted to unitgrade for question {q.name} and subquestion {item.name}. Framework will not work."
-            #         if strict:
-            #             raise Exception(s)
-            #         else:
-            #             print(s)
-            #     else:
-            #         item._correct_answer_payload = payloads[q.name][item.name]['payload']
-            #         item.estimated_time = payloads[q.name][item.name].get("time", 1)
-            #         q.estimated_time = payloads[q.name].get("time", 1)
-            #         if "precomputed" in payloads[q.name][item.name]: # Consider removing later.
-            #             item._precomputed_payload = payloads[q.name][item.name]['precomputed']
-            #         try:
-            #             if "title" in payloads[q.name][item.name]: # can perhaps be removed later.
-            #                 item.title = payloads[q.name][item.name]['title']
-            #         except Exception as e: # Cannot set attribute error. The title is a function (and probably should not be).
-            #             pass
-            #             # print("bad", e)
-        # self.payloads = payloads
-
-
 def rm_progress_bar(txt):
     # More robust version. Apparently length of bar can depend on various factors, so check for order of symbols.
     nlines = []
@@ -404,10 +398,9 @@ def extract_numbers(txt):
     all = [float(a) if ('.' in a or "e" in a) else int(a) for a in all]
     if len(all) > 500:
         print(txt)
-        raise Exception("unitgrade.unitgrade.py: Warning, many numbers!", len(all))
+        raise Exception("unitgrade.unitgrade.py: Warning, too many numbers!", len(all))
     return all
 
-
 class ActiveProgress():
     def __init__(self, t, start=True, title="my progress bar",show_progress_bar=True):
         self.t = t
@@ -459,8 +452,9 @@ class ActiveProgress():
 
 from unittest.suite import _isnotsuite
 
-class MySuite(unittest.suite.TestSuite): # Not sure we need this one anymore.
-    pass
+# class MySuite(unittest.suite.TestSuite): # Not sure we need this one anymore.
+#     raise Exception("no suite")
+#     pass
 
 def instance_call_stack(instance):
     s = "-".join(map(lambda x: x.__name__, instance.__class__.mro()))
@@ -616,8 +610,11 @@ class UTextResult(unittest.TextTestResult):
         n = UTextResult.number
 
         item_title = self.getDescription(test)
-        item_title = item_title.split("\n")[0]
-
+        # item_title = item_title.split("\n")[0]
+        item_title = test.shortDescription() # Better for printing (get from cache).
+        if item_title == None:
+            # For unittest framework where getDescription may return None.
+            item_title = self.getDescription(test)
         # test.countTestCases()
         self.item_title_print = "*** q%i.%i) %s" % (n + 1, j + 1, item_title)
         estimated_time = 10
@@ -632,7 +629,7 @@ class UTextResult(unittest.TextTestResult):
 
     def _setupStdout(self):
         if self._previousTestClass == None:
-            total_estimated_time = 2
+            total_estimated_time = 1
             if hasattr(self.__class__, 'q_title_print'):
                 q_title_print = self.__class__.q_title_print
             else:
@@ -688,7 +685,10 @@ def cache(foo, typed=False):
     """
     maxsize = None
     def wrapper(self, *args, **kwargs):
-        key = self.cache_id() + ("cache", _make_key(args, kwargs, typed))
+        key = (self.cache_id(), ("@cache", foo.__name__, _make_key(args, kwargs, typed)) )
+        # key = (self.cache_id(), '@cache')
+        # if self._cache_contains[key]
+
         if not self._cache_contains(key):
             value = foo(self, *args, **kwargs)
             self._cache_put(key, value)
@@ -700,15 +700,56 @@ def cache(foo, typed=False):
 
 class UTestCase(unittest.TestCase):
     _outcome = None # A dictionary which stores the user-computed outcomes of all the tests. This differs from the cache.
-    _cache = None  # Read-only cache.
-    _cache2 = None # User-written cache
+    _cache = None  # Read-only cache. Ensures method always produce same result.
+    _cache2 = None  # User-written cache.
+
+    def capture(self):
+        return Capturing2(stdout=self._stdout)
+
+    @classmethod
+    def question_title(cls):
+        """ Return the question title """
+        return cls.__doc__.strip().splitlines()[0].strip() if cls.__doc__ != None else cls.__qualname__
 
     @classmethod
     def reset(cls):
+        print("Warning, I am not sure UTestCase.reset() is needed anymore and it seems very hacky.")
         cls._outcome = None
         cls._cache = None
         cls._cache2 = None
 
+    def _callSetUp(self):
+        self._stdout = sys.stdout
+        import io
+        sys.stdout = io.StringIO()
+        super().setUp()
+        # print("Setting up...")
+
+    def _callTearDown(self):
+        sys.stdout = self._stdout
+        super().tearDown()
+        # print("asdfsfd")
+
+    def shortDescriptionStandard(self):
+        sd = super().shortDescription()
+        if sd == None:
+            sd = self._testMethodName
+        return sd
+
+    def shortDescription(self):
+        # self._testMethodDoc.strip().splitlines()[0].strip()
+        sd = self.shortDescriptionStandard()
+        title = self._cache_get(  (self.cache_id(), 'title'), sd )
+        return title if title != None else sd
+
+    @property
+    def title(self):
+        return self.shortDescription()
+
+    @title.setter
+    def title(self, value):
+        self._cache_put((self.cache_id(), 'title'), value)
+
     def _get_outcome(self):
         if not (self.__class__, '_outcome') or self.__class__._outcome == None:
             self.__class__._outcome = {}
@@ -716,37 +757,31 @@ class UTestCase(unittest.TestCase):
 
     def _callTestMethod(self, testMethod):
         t = time.time()
+        self._ensure_cache_exists() # Make sure cache is there.
+        if self._testMethodDoc != None:
+            # Ensure the cache is eventually updated with the right docstring.
+            self._cache_put((self.cache_id(), 'title'), self.shortDescriptionStandard() )
+        # Fix temp cache here (for using the @cache decorator)
+        self._cache2[ (self.cache_id(), 'assert') ] = {}
+
         res = testMethod()
         elapsed = time.time() - t
-        # if res == None:
-        #     res = {}
-        # res['time'] = elapsed
-        sd = self.shortDescription()
-        self._cache_put( (self.cache_id(), 'title'), self._testMethodName if sd == None else sd)
-        # self._test_fun_output = res
+        # self._cache_put( (self.cache_id(), 'title'), self.shortDescription() )
+
         self._get_outcome()[self.cache_id()] = res
         self._cache_put( (self.cache_id(), "time"), elapsed)
 
-
     # This is my base test class. So what is new about it?
     def cache_id(self):
         c = self.__class__.__qualname__
         m = self._testMethodName
         return (c,m)
 
-    def unique_cache_id(self):
-        k0 = self.cache_id()
-        key = ()
-        for i in itertools.count():
-            key = k0 + (i,)
-            if not self._cache2_contains(key):
-                break
-        return key
-
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self._load_cache()
-        self.cache_indexes = defaultdict(lambda: 0)
+        self._assert_cache_index = 0
+        # self.cache_indexes = defaultdict(lambda: 0)
 
     def _ensure_cache_exists(self):
         if not hasattr(self.__class__, '_cache') or self.__class__._cache == None:
@@ -766,17 +801,22 @@ class UTestCase(unittest.TestCase):
         self._ensure_cache_exists()
         return key in self.__class__._cache
 
-    def _cache2_contains(self, key):
-        self._ensure_cache_exists()
-        return key in self.__class__._cache2
+    def wrap_assert(self, assert_fun, first, *args, **kwargs):
+        key = (self.cache_id(), 'assert')
+        if not self._cache_contains(key):
+            print("Warning, framework missing", key)
+        cache = self._cache_get(key, {})
+        id = self._assert_cache_index
+        if not id in cache:
+            print("Warning, framework missing cache index", key, "id =", id)
+        _expected = cache.get(id, first)
+        assert_fun(first, _expected, *args, **kwargs)
+        cache[id] = first
+        self._cache_put(key, cache)
+        self._assert_cache_index += 1
 
     def assertEqualC(self, first: Any, msg: Any = ...) -> None:
-        id = self.unique_cache_id()
-        if not self._cache_contains(id):
-            print("Warning, framework missing key", id)
-
-        self.assertEqual(first, self._cache_get(id, first), msg)
-        self._cache_put(id, first)
+        self.wrap_assert(self.assertEqual, first, msg)
 
     def _cache_file(self):
         return os.path.dirname(inspect.getfile(self.__class__) ) + "/unitgrade/" + self.__class__.__name__ + ".pkl"
diff --git a/unitgrade2/unitgrade_helpers2.py b/unitgrade2/unitgrade_helpers2.py
index 35623369b567b754c869ef1c4d6d3b4748fb3705..84215620b4a2ee0821eb19d6da4c6f7cb527f7af 100644
--- a/unitgrade2/unitgrade_helpers2.py
+++ b/unitgrade2/unitgrade_helpers2.py
@@ -5,7 +5,7 @@ import pyfiglet
 from unitgrade2 import Hidden, myround, msum, mfloor, ActiveProgress
 from unitgrade2 import __version__
 import unittest
-from unitgrade2.unitgrade2 import MySuite
+# from unitgrade2.unitgrade2 import MySuite
 from unitgrade2.unitgrade2 import UTextResult
 
 import inspect
@@ -64,53 +64,6 @@ def evaluate_report_student(report, question=None, qitem=None, unmute=None, pass
                                           show_tol_err=show_tol_err)
 
 
-    # try:  # For registering stats.
-    #     import unitgrade_private
-    #     import irlc.lectures
-    #     import xlwings
-    #     from openpyxl import Workbook
-    #     import pandas as pd
-    #     from collections import defaultdict
-    #     dd = defaultdict(lambda: [])
-    #     error_computed = []
-    #     for k1, (q, _) in enumerate(report.questions):
-    #         for k2, item in enumerate(q.items):
-    #             dd['question_index'].append(k1)
-    #             dd['item_index'].append(k2)
-    #             dd['question'].append(q.name)
-    #             dd['item'].append(item.name)
-    #             dd['tol'].append(0 if not hasattr(item, 'tol') else item.tol)
-    #             error_computed.append(0 if not hasattr(item, 'error_computed') else item.error_computed)
-    #
-    #     qstats = report.wdir + "/" + report.name + ".xlsx"
-    #
-    #     if os.path.isfile(qstats):
-    #         d_read = pd.read_excel(qstats).to_dict()
-    #     else:
-    #         d_read = dict()
-    #
-    #     for k in range(1000):
-    #         key = 'run_'+str(k)
-    #         if key in d_read:
-    #             dd[key] = list(d_read['run_0'].values())
-    #         else:
-    #             dd[key] = error_computed
-    #             break
-    #
-    #     workbook = Workbook()
-    #     worksheet = workbook.active
-    #     for col, key in enumerate(dd.keys()):
-    #         worksheet.cell(row=1, column=col+1).value = key
-    #         for row, item in enumerate(dd[key]):
-    #             worksheet.cell(row=row+2, column=col+1).value = item
-    #
-    #     workbook.save(qstats)
-    #     workbook.close()
-    #
-    # except ModuleNotFoundError as e:
-    #     s = 234
-    #     pass
-
     if question is None:
         print("Provisional evaluation")
         tabulate(table_data)
@@ -142,7 +95,12 @@ class UnitgradeTextRunner(unittest.TextTestRunner):
 class SequentialTestLoader(unittest.TestLoader):
     def getTestCaseNames(self, testCaseClass):
         test_names = super().getTestCaseNames(testCaseClass)
-        testcase_methods = list(testCaseClass.__dict__.keys())
+        # testcase_methods = list(testCaseClass.__dict__.keys())
+        ls = []
+        for C in testCaseClass.mro():
+            if issubclass(C, unittest.TestCase):
+                ls = list(C.__dict__.keys()) + ls
+        testcase_methods = ls
         test_names.sort(key=testcase_methods.index)
         return test_names
 
@@ -174,12 +132,12 @@ def evaluate_report(report, question=None, qitem=None, passall=False, verbose=Fa
 
     for n, (q, w) in enumerate(report.questions):
         # q = q()
-        q_hidden = False
+        # q_hidden = False
         # q_hidden = issubclass(q.__class__, Hidden)
         if question is not None and n+1 != question:
             continue
         suite = loader.loadTestsFromTestCase(q)
-        qtitle = q.__name__
+        qtitle = q.question_title() if hasattr(q, 'question_title') else q.__qualname__
         q_title_print = "Question %i: %s"%(n+1, qtitle)
         print(q_title_print, end="")
         q.possible = 0
@@ -193,77 +151,6 @@ def evaluate_report(report, question=None, qitem=None, passall=False, verbose=Fa
         UTextResult.number = n
 
         res = UTextTestRunner(verbosity=2, resultclass=UTextResult).run(suite)
-        # res = UTextTestRunner(verbosity=2, resultclass=unittest.TextTestResult).run(suite)
-        z = 234
-        # for j, item in enumerate(q.items):
-        #     if qitem is not None and question is not None and j+1 != qitem:
-        #         continue
-        #
-        #     if q_with_outstanding_init is not None: # check for None bc. this must be called to set titles.
-        #         # if not item.question.has_called_init_:
-        #         start = time.time()
-        #
-        #         cc = None
-        #         if show_progress_bar:
-        #             total_estimated_time = q.estimated_time # Use this. The time is estimated for the q itself.  # sum( [q2.estimated_time for q2 in q_with_outstanding_init] )
-        #             cc = ActiveProgress(t=total_estimated_time, title=q_title_print)
-        #         from unitgrade import Capturing # DON'T REMOVE THIS LINE
-        #         with eval('Capturing')(unmute=unmute):  # Clunky import syntax is required bc. of minify issue.
-        #             try:
-        #                 for q2 in q_with_outstanding_init:
-        #                     q2.init()
-        #                     q2.has_called_init_ = True
-        #
-        #                 # item.question.init()  # Initialize the question. Useful for sharing resources.
-        #             except Exception as e:
-        #                 if not passall:
-        #                     if not silent:
-        #                         print(" ")
-        #                         print("="*30)
-        #                         print(f"When initializing question {q.title} the initialization code threw an error")
-        #                         print(e)
-        #                         print("The remaining parts of this question will likely fail.")
-        #                         print("="*30)
-        #
-        #         if show_progress_bar:
-        #             cc.terminate()
-        #             sys.stdout.flush()
-        #             print(q_title_print, end="")
-        #
-        #         q_time =np.round(  time.time()-start, 2)
-        #
-        #         print(" "* max(0,nL - len(q_title_print) ) + (" (" + str(q_time) + " seconds)" if q_time >= 0.1 else "") ) # if q.name in report.payloads else "")
-        #         print("=" * nL)
-        #         q_with_outstanding_init = None
-        #
-        #     # item.question = q # Set the parent question instance for later reference.
-        #     item_title_print = ss = "*** q%i.%i) %s"%(n+1, j+1, item.title)
-        #
-        #     if show_progress_bar:
-        #         cc = ActiveProgress(t=item.estimated_time, title=item_title_print)
-        #     else:
-        #         print(item_title_print + ( '.'*max(0, nL-4-len(ss)) ), end="")
-        #     hidden = issubclass(item.__class__, Hidden)
-        #     # if not hidden:
-        #     #     print(ss, end="")
-        #     # sys.stdout.flush()
-        #     start = time.time()
-        #
-        #     (current, possible) = item.get_points(show_expected=show_expected, show_computed=show_computed,unmute=unmute, passall=passall, silent=silent)
-        #     q_[j] = {'w': item.weight, 'possible': possible, 'obtained': current, 'hidden': hidden, 'computed': str(item._computed_answer), 'title': item.title}
-        #     tsecs = np.round(time.time()-start, 2)
-        #     if show_progress_bar:
-        #         cc.terminate()
-        #         sys.stdout.flush()
-        #         print(item_title_print + ('.' * max(0, nL - 4 - len(ss))), end="")
-        #
-        #     if not hidden:
-        #         ss = "PASS" if current == possible else "*** FAILED"
-        #         if tsecs >= 0.1:
-        #             ss += " ("+ str(tsecs) + " seconds)"
-        #         print(ss)
-
-        # ws, possible, obtained = upack(q_)
 
         possible = res.testsRun
         obtained = len(res.successes)