Skip to content

Latest commit

 

History

History
1481 lines (1255 loc) · 65.9 KB

File metadata and controls

1481 lines (1255 loc) · 65.9 KB

十四、测试数据持久性

代码的可重复单元测试很少比数据持久性更重要。代码可能会随着时间的推移而改变或被替换,甚至可能会变成一个完全不同的系统,用一种完全不同的语言编写,但一旦数据存在,它就可能比使用它的任何数量的代码库都活得长。可以说,系统中的数据是真正的业务价值通常存在的地方,因此测试与之交互并有可能破坏该价值的流程是极其重要的。

考虑到这一点,本章的大部分内容将集中在以下方面:

  • 编写此迭代中创建的数据对象和相关类的单元测试:
    • 新的hms_artisan
    • 新的hms_core
  • 这些测试与构建过程的集成

还添加了足够多的新功能,因此必须注意以下几点:

  • 新代码对构建过程的其他影响
  • 演示新代码,以及如何促进相关故事的接受
  • 新规范如何影响操作、使用、维护和退役问题

编写单元测试

为新数据对象类编写单元测试的大部分过程可以简单地遵循在以前的迭代中建立的过程:

  1. 为正在测试的包创建顶级测试模块。
  2. 识别被测试包的子模块,并为每个子模块创建相应的测试模块。
  3. 将对子测试模块的引用添加到包测试模块并导入其测试。
  4. 对于每个子测试模块:
    • 执行模块并为报告为缺失的每个项创建测试用例类
    • 执行模块并为报告为缺少的每个成员(属性或方法)创建测试方法

需要创建几个测试模块,每个模块对应于在本次迭代涉及的项目的src目录中创建的模块,产生以下结果:

  • hms_core/../data_objects.py → test_hms_core/test_data_objects.py(已测试,但此处列出是为了有一个完整的列表)
  • hms_artisan/../data_storage.py → test_hms_artisan/test_data_storage.py
  • hms_artisan/../artisan_objects.py → test_hms_artisan/test_artisan_objects.py
  • hms_core/../co_objects.py → test_hms_core/test_co_objects.py

测试 hms_artisan.data_ 存储

此时,hms_artisan.data_storage的单元测试都与JSONFileDataStore类的测试有关。由于该类的实际功能,单元测试的典型模式应用得很差(如果有的话)。它没有要测试的属性,并且可以测试的单类属性(_file_store_dir被派生类覆盖。

不过,可能值得断言默认属性是预期的,因为如果它不默认为None,可能会导致派生类和这些类的实例失败:

def test_file_store_dir(self):
    self.assertEqual(
        JSONFileDataObject._file_store_dir, None, 
        'JSONFileDataObject._file_store_dir is expected to provide '
        'a None default value that must be overridden by derived '
        'classes, but it is set to "%s" (%s)' % 
        (
            JSONFileDataObject._file_store_dir, 
            type(JSONFileDataObject._file_store_dir).__name__
        )
    )

就方法测试而言,虽然有几种方法,但它们在某种程度上是相互交织的,并且它们也经常依赖于方法的实现,这些方法本身是抽象的,因此在 ABC 本身中不可用:

  • getdeletesave都调用_load_objects助手类方法
  • _load_objects方法依赖于from_data_dict的具体实现,以生成其他方法引用的对象集合
  • save方法还需要to_data_dict方法的具体实现

既然单元测试是关于证明可预测的功能,那么问题就变成了:我们能证明什么?

第一项,也可能是最明显的一项是,对象初始化的工作方式与BaseDataObject中的工作方式几乎相同:

class testJSONFileDataObject(unittest.TestCase):

    ###################################
    # Tests of class methods          #
    ###################################

    def test__init__(self):
        # Tests the __init__ method of the JSONFileDataObject class
        # - All we need to do here is prove that the various 
        #   setter- and deleter-method calls are operating as 
        #   expected -- same as BaseDataObject
        # - deleters first
        test_object = JSONFileDataObjectDerived()
        self.assertEquals(test_object._created, None)
        self.assertEquals(test_object._is_active, True)
        self.assertEquals(test_object._is_deleted, False)
        self.assertEquals(test_object._is_dirty, False)
        self.assertEquals(test_object._is_new, True)
        self.assertEquals(test_object._modified, None)
        self.assertEquals(test_object._oid, None)
        # - setters
        oid = uuid4()
        created = GoodDateTimes[0]
        modified = GoodDateTimes[1]
        is_active = False
        is_deleted = True
        is_dirty = True
        is_new = False
        test_object = JSONFileDataObjectDerived(
            oid, created, modified, is_active, is_deleted, 
            is_dirty, is_new
        )
        self.assertEquals(test_object.oid, oid)
        self.assertEquals(test_object.created, created)
        self.assertEquals(test_object.is_active, is_active)
        self.assertEquals(test_object.is_deleted, is_deleted)
        self.assertEquals(test_object.is_dirty, is_dirty)
        self.assertEquals(test_object.is_new, is_new)
        self.assertEquals(test_object.modified, modified)

The GoodDateTimes test values are the same values we used to test BaseDataObject.

由于不使用_create_update方法,我们可以证明它们在调用时会产生预期的错误:

def test_create(self):
   # Tests the _create method of the JSONFileDataObject class
     test_object = JSONFileDataObjectDerived()
       try:
         test_object._create()
         self.fail(
           'JSONFileDataObject is not expected to raise '
            'NotImplementedError on a call to _create'
          )
        except NotImplementedError:
            pass
        except Exception as error:
            self.fail(
                'JSONFileDataObject is not expected to raise '
                'NotImplementedError on a call to _create, but %s '
                'was raised instead:\n - %s' %
                (error.__class__.__name__, error)
            )

def test_update(self):
   # Tests the _update method of the JSONFileDataObject class
   test_object = JSONFileDataObjectDerived()
     try:
         test_object._update()
         self.fail(
            'JSONFileDataObject is not expected to raise '
            'NotImplementedError on a call to _update'
          )
      except NotImplementedError:
         pass
      except Exception as error:
         self.fail(
             'JSONFileDataObject is not expected to raise '
             'NotImplementedError on a call to _update, but %s '
             'was raised instead:\n - %s' %
             (error.__class__.__name__, error)
          )

单独的 CRUD 操作,加上_load_objects方法,因为它们在臀部连接,最终会有很多重叠——一种方法的测试必须执行其他方法的测试,作为其自身测试过程的一部分,以真正证明一切都按预期工作。这种复杂的测试编写起来很乏味,但更重要的是,需要更多的努力和纪律来维护,因此更容易与他们正在测试的代码脱节。在这种情况下,更好的选择可能是跳过这些测试,并创建一个更大的、统一的所有相关功能测试。Python 的 stockunittest模块提供了一个skipdecorator 函数,该函数能够标记标准单元测试运行要跳过的测试,并且调用该函数需要记录跳过测试的原因。在这种情况下,原因是所有有问题的方法都将在不同的测试方法中一次通过测试:

@unittest.skip(
    'Since the file-load process provided by _load_objects is '
    'used by many of the CRUD operations, it is tested  as part of '
    'testCRUDOperations'
  )
def test_load_objects(self):
    # Tests the _load_objects method of the JSONFileDataObject class
      self.fail('test_load_objects is not yet implemented')

@unittest.skip(
    'Since deleting a data-file is part of the CRUD operations, '
    'it is tested as part of testCRUDOperations'
  )
def testdelete(self):
    # Tests the delete method of the JSONFileDataObject class
      self.fail('testdelete is not yet implemented')

@unittest.skip(
    'Since reading data-files is part of the CRUD operations, '
    'it is tested as part of testCRUDOperations'
  )
def testget(self):
    # Tests the get method of the JSONFileDataObject class
    self.fail('testget is not yet implemented')

@unittest.skip(
    'Since creating a data-file is part of the CRUD operations, '
    'it is tested as part of testCRUDOperations'
  )
def testsave(self):
     # Tests the save method of the JSONFileDataObject class
     self.fail('testsave is not yet implemented')

这就把测试大部分JSONFileDataObject的责任交给了一个测试方法——执行标准测试策略的代码不需要这个方法,但它代表了单个类成员测试覆盖率和可维护性之间的最佳折衷:testCRUDOperations。这里没有太多优雅的机会;仅仅因为被测试方法的性质,它必须在很多条件和对象状态下强行通过。但是,如果经过深思熟虑,它会让派生自它的类的测试不必测试公共功能。

它必须做的第一件事是确保在内存和文件系统中都有一个干净的对象存储库。为了做到这一点,必须定义一个一次性类,并提供所需功能的最低限度,以确保生成所有必要的方法类。那个类,JSONFileDataObjectDerived看起来像这样:

class JSONFileDataObjectDerived(JSONFileDataObject):

我们提供的文件存储位置不被任何真实对象使用,可以使用对象数据删除和重新创建该位置,但需要时:

_file_store_dir = '/tmp/hms_artisan_test'

Because these tests are concerned with file system data-persistence, they were written for the OS that system development was undertaken on—a Linux installation—though they would execute without modification on any Unix-like OS. Converting them to run under Windows isn't difficult: Create a test-data directory (C:\TestData, for example), and change all filesystem references that start with /tmp/ to C:\\TestData\\ (note the double-backslashes), and alter the remaining filesystem paths to use Windows' filesystem notation (C:\\TestData\\path\\to\\some\\file.ext, note the double-backslashes again).

我们提供所需的最低限度的功能,尽可能使用父类的默认值或经验证/可证明的功能,或最简单的实现:

def matches(self, **criteria) -> (bool,):
   return BaseDataObject.matches(self, **criteria)

@classmethod
def from_data_dict(cls, data_dict:(dict,)):
   return cls(**data_dict)

如果没有默认或可继承的功能可用,我们将保持测试有意义所需的最低限度–在to_data_dict方法的情况下,这意味着坚持从BaseDataObject派生的所有类所需的属性和数据结构,包括JSONFileDataObject

def to_data_dict(self):
   return {
        'created':datetime.strftime(
         self.created, self.__class__._data_time_string
         ),
         'is_active':self.is_active,
         'is_deleted':self.is_deleted,
         'modified':datetime.strftime(
             self.modified, self.__class__._data_time_string
          ),
          'oid':str(self.oid),
        }

这样,我们就可以通过直接清除内存中的对象缓存并删除存储位置中的任何文件来启动testCRUDOperations测试方法:

def testCRUDOperations(self):
   # - First, assure that the class-level data-object collection 
   #   (in JSONFileDataObjectDerived._loaded_objects) is None, 
   #   and that the file-repository does not exist.
   JSONFileDataObjectDerived._loaded_objects = None
   if os.path.exists(JSONFileDataObjectDerived._file_store_dir):
      rmtree(JSONFileDataObjectDerived._file_store_dir)

The rmtree function is from a Python package called shutils, and recursively deletes files and sub directories from a specified location, raising an error if the target location doesn't exist. The os.path.exists call, from the built-in os module, checks for the existence of a file or directory at the specified path, returning True if something exists there, and False otherwise.

我们需要至少一个存储在新清除的缓存中的对象来启动测试过程,因此接下来要创建数据对象并保存其状态数据:

# - Next, create an item and save it
first_object = JSONFileDataObjectDerived()
first_object.save()
# - Verify that the file exists where we're expecting it
self.assertTrue(
    os.path.exists(
         '/tmp/hms_artisan_test/JSONFileDataObjectDerived-'
         'data/%s.json' % first_object.oid
       )
    )
# - and that it exists in the in-memory cache
    self.assertNotEqual(
          JSONFileDataObjectDerived._loaded_objects.get(
            str(first_object.oid)
          ), None
    )

通过创建并保存一个对象,我们可以验证数据写入和读取过程是否允许我们读取预期写入的相同数据。我们可以利用类的matches方法,因为它最终是从BaseDataObject继承的,并且已经在前面进行了测试。

由于matches使用to_data_dict生成的data dict,不包括is_dirtyis_new等不存在的属性,需要分别检查:

# - Verify that the item can be retrieved, and has the same 
#   data
first_object_get = JSONFileDataObjectDerived.get()[0]
self.assertTrue(
      first_object.matches(**first_object_get.to_data_dict())
)
self.assertEqual(
      first_object.is_dirty, first_object_get.is_dirty
)
self.assertEqual(
      first_object.is_new, first_object_get.is_new
)

A viable alternative, if any concerns arise about using matches as a data-structure-verification process, would be to explicitly check each property of the retrieved object against the corresponding property of the original. Using matches is a convenience, not a requirement.

接下来,我们将检查以确保多个对象按预期保存和读取。由于对象的文件和键都是对象的oid函数,并且我们现在知道数据对象的文件和内存副本与一个对象的创建一起工作,所以我们只需要确保倍数不会破坏任何东西。再创建两个对象还允许我们稍后重新验证整个集合:

# - Create and save two more items
second_object = JSONFileDataObjectDerived()
second_object.save()
third_object = JSONFileDataObjectDerived()
third_object.save()
# - Verify that all three items can be retrieved, and that 
#   they are the expected objects, at least by their oids: 
#   Those, as part of the file-names, *will* be unique and 
#   distinct...
all_objects = JSONFileDataObjectDerived.get()
expected = set(
     [o.oid for o in [first_object, second_object, third_object]]
)
actual = set([o.oid for o in all_objects])
self.assertEqual(expected, actual)

我们还需要测试删除是否按预期进行,从内存缓存中删除删除的对象并删除适用的文件。在执行删除之前,我们需要确认要删除的文件是否存在,以避免执行删除后出现假阳性测试结果:

# - Verify that the file for the second item exists, so the 
#   verification later of its deletion is a valid test
self.assertTrue(
    os.path.exists(
        '/tmp/hms_artisan_test/JSONFileDataObjectDerived-'
        'data/%s.json' % second_object.oid
     )
)

然后,我们可以删除该项,并验证是否从内存和文件系统中删除:

# - Delete the second item
JSONFileDataObjectDerived.delete(second_object.oid)
# - Verify that the item has been removed from the loaded-
#   object store and from the filesystem
self.assertEqual(
            JSONFileDataObjectDerived._loaded_objects.get(second_object.oid), 
            None
)
self.assertFalse(
os.path.exists(
        '/tmp/hms_artisan_test/JSONFileDataObjectDerived-'
        'data/%s.json' % second_object.oid
     )
 )

我们还需要验证更新状态数据的数据写入是否有效。我们可以通过更改现有对象的is_activeis_deleted标志,然后将其保存,并检索其副本进行比较,然后使用matches进行检查:

# - Update the last object created, and save it
third_object._set_is_active(False)
third_object._set_is_deleted(True)
third_object.save()
# - Read the updated object and verify that the changes made 
#   were saved to the file.
third_object_get = JSONFileDataObjectDerived.get(third_object.oid)[0]
self.assertEqual(
       third_object.to_data_dict(),
       third_object_get.to_data_dict()
     )
self.assertTrue(
       third_object.matches(**third_object_get.to_data_dict())
     )
self.assertEqual(
       third_object.is_dirty, third_object_get.is_dirty
     )
self.assertEqual(
       third_object.is_new, third_object_get.is_new
     )

如果以后可能会将其他测试添加到此测试用例类中,并且为了在不再需要这些测试时清理文件,我们将重复清除内存和磁盘对象存储的过程。如果以后为任何目的而创建的其他测试需要从处于任何特定状态的内存和磁盘存储开始,他们将不得不安排设置该状态,但他们不必担心首先清除它:

# - Since other test-methods down the line might need to start 
#   with empty object- and file-sets, re-clear them both
JSONFileDataObjectDerived._loaded_objects = None
if os.path.exists(JSONFileDataObjectDerived._file_store_dir):
   rmtree(JSONFileDataObjectDerived._file_store_dir)
self.fail('testCRUDOperations is not complete')

原始的test_file_store_dir测试方法没有考虑证明派生类在没有_file_store_dir类属性设置为None以外的其他属性的情况下不允许自己被实例化。对其进行修改,并使用从JSONFileDataObject派生的另一个类(本质上是用于 CRUD 操作测试的JSONFileDataObjectDerived类的副本,但没有属性规范),允许将其作为原始测试方法的一部分进行测试,如下所示:

###################################
# Tests of class properties       #
###################################

def test_file_store_dir(self):
  self.assertEqual(
      JSONFileDataObject._file_store_dir, None, 
      'JSONFileDataObject._file_store_dir is expected to provide '
      'a None default value that must be overridden by derived '
      'classes, but it is set to "%s" (%s)' % 
      (
           JSONFileDataObject._file_store_dir, 
           type(JSONFileDataObject._file_store_dir).__name__
      )
    )
    try:
       test_object = NoFileStoreDir()
       self.fail(
           'Classes derived from JSONFileDataObject are expected '
           'to define a _file_store_dir class-attribute, or cause '
           'instantiation of objects from classes that don\'t '
           'have one defined to fail with an AttributeError'
       )
     except AttributeError:
         pass

测试 hms_artisan.artisan_ 对象

在初始单元测试设置之后,有 74 个测试需要实现,这主要是由于hms_core中的Base对应类重写了属性及其 setter 和 deleter 方法。由于属性及其重写方法之间的主要区别在于在 set 或 delete 调用期间在实例的is_dirty属性中包含一个自动更改,这似乎是该级别的属性相关测试需要关注的唯一问题:

这些属性的测试都接近到目前为止使用的标准结构,基本上验证每个属性是否具有适当的 getter、setter 和 deleter 方法关联。唯一真正的区别在于指定了哪些方法。以测试Artisan.contact_nametestArtisan.testcontact_name为例,setter 和 deleter 方法的测试断言在结构上与BaseArtisan测试中的对应项相同——它们断言 Artisan setter 和 deleter 方法与属性的 set 和 delete 操作相关联。

getter 方法断言是事情变得不同的地方:

def testcontact_name(self):
    # Tests the contact_name property of the Artisan class
    # - Assert that the getter is correct:
    self.assertEqual(
        BaseArtisan.contact_name.fget, 
        Artisan._get_contact_name, 
        'Artisan.contact_name is expected to use the '
        'BaseArtisan._get_contact_name method as its getter-method'
    )
    # - Assert that the setter is correct:
    self.assertEqual(
        Artisan.contact_name.fset, 
        Artisan._set_contact_name, 
        'Artisan.contact_name is expected to use the '
        '_set_contact_name method as its setter-method'
    )
    # - Assert that the deleter is correct:
    self.assertEqual(
        Artisan.contact_name.fdel, 
        Artisan._del_contact_name, 
        'Artisan.contact_name is expected to use the '
        '_del_contact_name method as its deleter-method'
    )

由于Artisan类为每个 setter 和 deleter 方法提供了重写方法,但没有为 getter 方法提供重写方法,因此属性的该方面的断言指向原始 getter 方法,在本例中,是在BaseArtisan中定义并从中继承的方法。即使对于没有本地 setter 或 deleter 方法的属性,例如由testProduct.testmetadata测试的Product.metadata,同样的基本模式也适用:

def testmetadata(self):
    # Tests the metadata property of the Product class
    # - Assert that the getter is correct:
    self.assertEqual(
        Product.metadata.fget, 
        BaseProduct._get_metadata, 
        'Product.metadata is expected to use the '
        'BaseProduct._get_metadata method as its getter-method'
    )
    # - Assert that the setter is correct:
    self.assertEqual(
        Product.metadata.fset, 
        None, 
        'Product.metadata is expected to be read-only, with no setter'
    )
    # - Assert that the deleter is correct:
    self.assertEqual(
        Product.metadata.fdel, 
        None, 
        'Product.metadata is expected to be read-only, with no deleter'
    )

setter 和 deleter 方法本身的测试也可以非常简单,但需要注意。如果基本假设是:

  • hms_core.business_objects中的Base类继承的所有属性都将被测试(目前的情况是这样的)
  • 当设置或删除这些属性时,可以信任这些测试来证明这些属性的可预测行为
  • 本地 setter 和 deleter 方法将始终回调到它们的测试对手

然后,在测试本地方法时需要做的就是检查它们是否相应地设置了is_dirty。然而,实际上可能没有任何方法来验证这些假设是否作为单元测试集的一部分发挥作用。了解这些项目是预期的、标准的程序,并在开发新代码时维护这些程序就成了一个问题。如果可以依赖这些原则和过程,则派生类属性方法重写的测试不需要像它们的祖先那样进行同样的工作/详细程度的测试,并且可以像下面这样简单:

def test_del_address(self):
    # Tests the _del_address method of the Artisan class
    test_object = Artisan('name', 'me@email.com', GoodAddress)
    self.assertEqual(test_object.is_dirty, False, 
        'A newly-created instance of an Artisan should '
        'have is_dirty of False'
    )
    test_object._del_address()
    self.assertEqual(test_object.is_dirty, True, 
        'The deletion of an Artisan address should set '
        'is_dirty to True'
    )

# ...

def test_set_address(self):
    # Tests the _set_address method of the Artisan class
    test_object = Artisan('name', 'me@email.com', GoodAddress)
    self.assertEqual(test_object.is_dirty, False, 
        'A newly-created instance of an Artisan should '
        'have is_dirty of False'
    )
    test_object._set_address(GoodAddresses[0])
    self.assertEqual(test_object.is_dirty, True, 
        'Setting an Artisan address should set '
        'is_dirty to True'
    )

数据 dict 方法(to_data_dictfrom_data_dict在所有数据对象中都是通用的,因此会显示在所有测试用例类中要实现的测试列表中。在编写好的、彻底的单元测试方面,他们都有自己的特殊挑战。to_data_dict的变化都遵循一个非常一致的模式:

  1. 迭代输出中应该出现的每个属性的代表值列表(希望很短)
  2. 创建预期的字典值,该值可用于将输出与
  3. 断言预期的字典和to_data_dict的结果相同

理论上,确保测试所有可能的好值和坏值组合的最佳方法是迭代所有这些可能的组合,在其他循环中嵌套循环,以便测试所有可能的namestreet_addresscity值组合。在实践中,使用该策略构建的测试将花费很长的时间来执行,需要测试大量的组合(例如name值的数量×street_address值的数量×city值的数量等等)。在数据 dict 表示中需要出现的属性最少的类是Order类,除了从已测试的其他类继承的属性外,还有五个本地属性。相关testto_data_dict方法的一个不完整的开始,只有一个属性包含在混合物中,共有 72 行:

def testto_data_dict(self):
    # Tests the to_data_dict method of the Order class
    for name in GoodStandardRequiredTextLines[0:2]:
        for street_address in GoodStandardRequiredTextLines[0:2]:
            for city in GoodStandardRequiredTextLines[0:2]:
                # - At this point, we have all the required 
                #   arguments, so we can start testing with 
                #   partial expected dict-values
                test_object = Order(
                    name, street_address, city,
                )
                expected = {
                    'name':name,
                    'street_address':street_address,
                    'city':city,
                    # - The balance are default values...
                    'building_address':None,
                    'region':None,
                    'postal_code':None,
                    'country':None,
                    'items':{},
                    # - We also need to include the data-object 
                    #   items that should appear!
                    'created':datetime.strftime(
                            test_object.created, 
                            test_object._data_time_string
                        ),
                    'modified':datetime.strftime(
                            test_object.modified, 
                            test_object._data_time_string
                        ),
                    'oid':str(test_object.oid),
                    'is_active':test_object.is_active,
                    'is_deleted':test_object.is_deleted,
                }
                self.assertEqual(
                    test_object.to_data_dict(), expected
                )

每个需要测试的附加属性都会导致当前循环中的另一个循环,并创建一个新的测试对象,确保包括正在测试的新属性项/参数:

for items in GoodOrderItems:
  test_object = Order(
       name, street_address, city,
       items=items,
  )

每个子循环必须创建自己的expected值:

expected = {
    'name':name,
    'street_address':street_address,
    'city':city,
    'building_address':None,
    'region':None,
    'postal_code':None,
    'country':None,
    'items':items,
    'created':datetime.strftime(
         test_object.created, 
         test_object._data_time_string
     ),
    'modified':datetime.strftime(
         test_object.modified, 
         test_object._data_time_string
     ),
     'oid':str(test_object.oid),
     'is_active':test_object.is_active,
     'is_deleted':test_object.is_deleted,
}

每个子循环还必须执行自己的断言,根据test_object.to_data_dict调用返回的实际值测试expected

self.assertEqual(
     test_object.to_data_dict(), expected
)

此时,还有四个属性需要测试,每个属性都将从自己的嵌套循环开始:

for building_address in GoodStandardOptionalTextLines[0:2]:
    for region in GoodStandardOptionalTextLines[0:2]:
        for postal_code in GoodStandardOptionalTextLines[0:2]:
            for country in GoodStandardOptionalTextLines[0:2]:
                pass

强制失败(带有测试方法不完整的标记)有助于防止误报,也有助于在大量结果列表中跟踪正在进行的测试:

self.fail('testto_data_dict is not complete')

各种from_data_dict方法的测试同样复杂且嵌套深入,原因相同——它们必须考虑可能提供的所有值的合理可能性。在Order类中测试该方法的一个不完整的开始显示了在 72 行中开始形成的模式:

def testfrom_data_dict(self):
    # Tests the from_data_dict method of the Order class

由于在每个迭代段的期望值中,某些结果应该总是有默认的None值,我们可以定义它们一次,然后在需要的每个点将它们添加到期望值中:

defaults = {
   'building_address':None,
   'region':None,
   'postal_code':None,
   'country':None,
   'items':{},
}

嵌套循环的集合本身与测试to_data_dict的集合相同,从所有必需属性/参数的变体开始:

for name in GoodStandardRequiredTextLines[0:2]:
    for street_address in GoodStandardRequiredTextLines[0:2]:
        for city in GoodStandardRequiredTextLines[0:2]:

每个回路段需要创建一个包含当前值的data_dict,并创建一个测试对象:

# - At this point, we have all the required 
#   arguments, so we can start testing with 
#   partial expected dict-values
    data_dict = {
        'name':name,
        'street_address':street_address,
        'city':city,
    }
    test_object = Order.from_data_dict(data_dict)

由于我们还将测试to_data_dict,因此我们可以假设它是可信的,以便与测试对象的data-dict进行比较。如果to_data_dict测试失败,他们将自行提出这些失败,并且在解决这些失败之前不允许测试运行通过,同时净结果测试失败:

actual = test_object.to_data_dict()

创建期望值要复杂一些。它从前面的defaults值的副本开始(因为我们不希望测试迭代污染主默认值)。我们还需要从实例中捕获期望值,因为我们期望它们出现在最终数据目录中:

# - Create a copy of the defaults as a starting-point
expected = dict(defaults)
instance_values = {
    'created':datetime.strftime(
           test_object.created, 
           test_object._data_time_string
         ),
     'modified':datetime.strftime(
           test_object.modified, 
           test_object._data_time_string
         ),
     'oid':str(test_object.oid),
     'is_active':test_object.is_active,
     'is_deleted':test_object.is_deleted,
   }

那么,此时构建expected值只需使用数据 dict 和实例值对其进行更新。完成后,我们可以执行实际的测试断言:

expected.update(instance_values)
expected.update(data_dict)
self.assertEqual(expected, actual)

与前面一样,需要测试的每个属性/参数都需要自己的嵌套循环,以及来自最顶层循环的同一进程的副本。在每个连续循环级别,data_dict值必须包含越来越多的数据才能传递给from_data_dict方法,但每个子循环的平衡在其他方面是相同的:

for items in GoodOrderItems:
   # - Same structure as above, but adding items
   data_dict = {
        'name':name,
        'street_address':street_address,
        'city':city,
        'items':items,
    }
    test_object = Order.from_data_dict(data_dict)
    actual = test_object.to_data_dict()
    expected = dict(defaults)
    instance_values = {
        'created':datetime.strftime(
                 test_object.created, 
                 test_object._data_time_string
               ),
        'modified':datetime.strftime(
                 test_object.modified, 
                 test_object._data_time_string
               ),
         'oid':str(test_object.oid),
         'is_active':test_object.is_active,
         'is_deleted':test_object.is_deleted,
    }
    expected.update(instance_values)
    expected.update(data_dict)
    self.assertEqual(expected, actual)
    for building_address in GoodStandardOptionalTextLines[0:2]:
    for region in GoodStandardOptionalTextLines[0:2]:
    for postal_code in GoodStandardOptionalTextLines[0:2]:
    for country in GoodStandardOptionalTextLines[0:2]:
        pass
self.fail('testfrom_data_dict is not complete')

结果证明,测试matches方法并不像乍一看所预期的那么复杂。毕竟,一个完整的测试需要测试对象实例的所有属性的TrueFalse结果,标准可能是 1 或 12,或者(理论上)几十或数百。幸运的是,通过使用to_data_dictfrom_data_dict测试中使用的相同嵌套循环结构,但改变它以创建测试中使用的标准,并确定过程中每一步需要的预期值,其实并不难。测试过程从在每个属性中创建一个具有已知功能数据的对象开始:

def testmatches(self):
    # Tests the matches method of the Order class
    # - First, create an object to test against, with as complete 
    #   a data-set as we can manage
    test_object = Order(
        name = GoodStandardRequiredTextLines[0],
        street_address = GoodStandardRequiredTextLines[0],
        city = GoodStandardRequiredTextLines[0],
        building_address = GoodStandardOptionalTextLines[0],
        region = GoodStandardOptionalTextLines[0],
        postal_code = GoodStandardOptionalTextLines[0],
        country = GoodStandardOptionalTextLines[0],
    )

嵌套循环结构在一系列数字(01上进行迭代,并根据循环中的属性所关联、创建或添加到标准的值类型从相应列表中检索测试值,并确定预期结果是True还是False基于任何先前的预期值以及循环的条件值与相应对象属性的比较。之后剩下的就是断言期望值等于调用测试对象的matches方法得到的实际值:

# - Then we'll iterate over some "good" values, create criteria
for name_num in range(0,2):
   name = GoodStandardRequiredTextLines[name_num]
   criteria = {'name':name}
   expected = (name == test_object.name)
   self.assertEqual(expected, test_object.matches(**criteria))

每个子循环关注其父循环中设置的expected值的原因是确保较高循环级别的False结果不会被当前循环级别的潜在True结果覆盖。例如,在测试迭代的这一点上,如果name产生False结果(因为它与test_object.name不匹配),即使street_address匹配,它仍然应该返回False结果:

for str_addr_num in range(0,2):
    street_address = GoodStandardRequiredTextLines[str_addr_num]
    criteria['street_address'] = street_address
    expected = (expected and street_address == test_object.street_address)
    self.assertEqual(expected, test_object.matches(**criteria))

每个子循环的模式,除了添加到条件中的属性值的名称和expected值的重新定义外,在循环树的整个过程中都是相同的:

for city_num in range(0,2):
   city = GoodStandardRequiredTextLines[city_num]
   criteria['city'] = city
   expected = (expected and city == test_object.city)
   self.assertEqual(expected, test_object.matches(**criteria))
   for bldg_addr_num in range(0,2):
       building_address = GoodStandardOptionalTextLines[bldg_addr_num]
       criteria['building_address'] = building_address
         expected = (
             expected and 
             building_address == test_object.building_address
            )
            self.assertEqual(expected, test_object.matches(**criteria))
            for region_num in range(0,2):
                for pc_num in range(0,2):
                    for cntry_num in range(0,2):
                        country=GoodStandardOptionalTextLines[cntry_num]
self.fail('testmatches is not complete')

所有新数据对象通用的最后一个剩余方法是_load_objectshelper 类方法。初始单元测试提出了一些语法问题,因此有必要删除JSONFileDataObject中方法的抽象,并在每个下级类中实现一个重写类方法,所有这些类都调用原始类方法,如下所示:

@classmethod
def _load_objects(cls, force_load=False):
    return JSONFileDataObject._load_objects(cls, force_load)

这反过来又开始提高测试运行中方法的测试方法要求。这些测试的实施并不困难,在某种程度上建立在为JSONFileDataObject编写的原始测试方法的基础上。针对Order类的测试结构是最简单的示例,其启动方式大致相同,但强制清除磁盘上和内存中的数据存储,但将磁盘上的位置设置为一次性目录后:

def test_load_objects(self):
    # Tests the _load_objects method of the Order class
    # - First, forcibly change Order._file_store_dir to a disposable 
    #   temp-directory, and clear the in-memory and on-disk stores
    Order._file_store_dir = '/tmp/test_artisan_objects/'
    Order._loaded_objects = None
    if os.path.exists(Order._file_store_dir):
        rmtree(Order._file_store_dir)
    self.assertEqual(Order._loaded_objects, None)

同样,为了测试加载过程,有必要创建并保存一些对象:

# - Iterate through some objects, creating them and saving them.
    for name in GoodStandardRequiredTextLines[0:2]:
       for street_address in GoodStandardRequiredTextLines[0:2]:
          for city in GoodStandardRequiredTextLines[0:2]:
              test_object = Order(name, street_address, city)
              test_object.save()

创建每个对象时,将验证其在内存和磁盘存储中的存在:

# - Verify that the object exists
#   - in memory
self.assertNotEqual(
    Order._loaded_objects.get(str(test_object.oid)), 
    None
)
#   - on disk
file_path = '%s/Order-data/%s.json' % (
    Order._file_store_dir, test_object.oid
)
self.assertTrue(
    os.path.exists(file_path), 
    'The file was not written at %s' % file_path
)

还需要清除内存中的存储,重新加载它,并验证新创建的对象是否仍然存在。这在每个对象创建迭代中都会发生:

# - Make a copy of the OIDs to check with after clearing 
#   the in-memory copy:
oids_before = sorted([str(key) for key in Order._loaded_objects.keys()])
# - Clear the in-memory copy and verify all the oids 
#   exist after a _load_objects is called
Order._loaded_objects = None
Order._load_objects()
oids_after = sorted(
    [str(key) for key in Order._loaded_objects.keys()]
)
self.assertEqual(oids_before, oids_after)

验证删除过程是否删除内存和磁盘上的对象,方法是迭代实例列表,随机选择一个实例,删除该实例,并以验证初始创建的相同方式验证其删除:

# - Delete items at random and verify deletion and load after each
instances = list(Order._loaded_objects.values())
while instances:
   target = choice(instances)
   Order.delete(target.oid)
   # - Verify that the object no longer exists
   #   - in memory
   self.assertEqual(
       Order._loaded_objects.get(str(test_object.oid)), 
       None
   )
   #   - on disk
   file_path = '%s/Order-data/%s.json' % (
       Order._file_store_dir, target.oid
   )
   self.assertFalse(
        os.path.exists(file_path), 
        'File at %s was not deleted' % file_path
   )
   # - Make a copy of the OIDs to check with after clearing 
   #   the in-memory copy:
   oids_before = sorted(
        [str(key) for key in Order._loaded_objects.keys()]
   )
   # - Clear the in-memory copy and verify all the oids 
   #   exist after a _load_objects is called
   Order._loaded_objects = None
   Order._load_objects()
   oids_after = sorted([str(key) for key in Order._loaded_objects.keys()])
   self.assertEqual(oids_before, oids_after)

实例列表在每次迭代结束时更新:

instances.remove(target)

最后,为了安全起见,删除所有可能保留的文件:

# - Clean up any remaining in-memory and on-disk store items
Order._loaded_objects = None
if os.path.exists(Order._file_store_dir):
    rmtree(Order._file_store_dir)

大多数平衡测试方法遵循之前确定的模式:

  • 各种属性及其 getter、setter 和 deleter 方法使用本节开头提到的结构
  • 各种__init__方法仍然为所有参数/属性的合理良好值子集创建并断言参数到属性设置

不过,也有一些异常值。首先,作为BaseDataObject中的抽象类方法,未经实现而定义的sort类方法已经浮出水面。在这一点上,我们甚至不知道我们是否需要它,更不用说它需要什么形状。在这种情况下,推迟其实施和对该实施的测试是明智的。为了允许忽略所需的单元测试,可使用unittest.skip进行装饰:

@unittest.skip(
    'Sort will be implemented once there\'s a need for it, '
    'and tested as part of that implementation'
)
def testsort(self):
    # Tests the sort method of the Artisan class
    # - Test all permutations of "good" argument-values:
    # - Test all permutations of each "bad" argument-value 
    #   set against "good" values for the other arguments:
    self.fail('testsort is not yet implemented')

Artisan 类中又出现了两个异常值:add_productremove_product,这两个类之前没有可测试的具体实现。通过添加GoodproductsBadproducts值列表进行测试,testadd_product与之前使用值列表进行测试的测试方法非常相似:

def testadd_product(self):
    # Tests the add_product method of the Artisan class
    test_object = Artisan('name', 'me@email.com', GoodAddress)
    self.assertEqual(test_object.products, ())
    check_list = []
    for product in Goodproducts[0]:
        test_object.add_product(product)
        check_list.append(product)
        self.assertEqual(test_object.products, tuple(check_list))
    test_object = Artisan('name', 'me@email.com', GoodAddress)
    for product in Badproducts:
        try:
            test_object.add_product(product)
            self.fail(
                'Artisan.add_product should not allow the '
                'addition of "%s" (%s) as a product-item, but '
                'it was allowed' % (product, type(product).__name__)
            )
        except (TypeError, ValueError):
            pass

测试remove_product的过程从使用相同的过程创建产品集合开始,然后一次移除一个产品,在每次迭代中验证移除情况:

def testremove_product(self):
    # Tests the remove_product method of the Artisan class
    test_object = Artisan('name', 'me@email.com', GoodAddress)
    self.assertEqual(test_object.products, ())
    for product in Goodproducts[0]:
        test_object.add_product(product)
    check_list = list(test_object.products)
    while test_object.products:
        product = test_object.products[0]
        check_list.remove(product)
        test_object.remove_product(product)
        self.assertEqual(test_object.products, tuple(check_list))

因为hms_artisan..Order是从头开始构建的,它的属性方法测试需要显式地执行前面提到的相同类型的is_dirty检查,但也必须执行几个标准属性测试中的任何一个。典型的 deleter 和 setter 方法测试如下所示:

def test_del_building_address(self):
    # Tests the _del_building_address method of the Order class
    test_object = Order('name', 'street_address', 'city')
    self.assertEqual(
        test_object.building_address, None, 
        'An Order object is expected to have None as its default '
        'building_address value if no value was provided'
    )
    # - Hard-set the storage-property's value, call the 
    #   deleter-method, and assert that it's what's expected 
    #   afterwards:
    test_object._set_is_dirty(False)
    test_object._building_address = 'a test value'
    test_object._del_building_address()
    self.assertEqual(
        test_object.building_address, None, 
        'An Order object is expected to have None as its '
        'building_address value after the deleter is called'
    )
    self.assertTrue(test_object.is_dirty,
        'Deleting Order.building_address should set is_dirty to True'
    )

# ...

def test_set_building_address(self):
    # Tests the _set_building_address method of the Order class
    # - Create an object to test with:
    test_object = Order('name', 'street_address', 'city')
    # - Test all permutations of "good" argument-values:
    for expected in GoodStandardOptionalTextLines:
        test_object._set_building_address(expected)
        actual = test_object._get_building_address()
        self.assertEqual(
            expected, actual, 
            'Order expects a building_address value set to '
            '"%s" (%s) to be retrieved with a corresponding '
            'getter-method call, but "%s" (%s) was returned '
            'instead' % 
            (
                expected, type(expected).__name__, 
                actual, type(actual).__name__, 
            )
        )
    # - Test is_dirty after a set
    test_object._set_is_dirty(False)
    test_object._set_building_address(GoodStandardOptionalTextLines[1])
    self.assertTrue(test_object.is_dirty,
        'Setting a new value in Order.business_address should '
        'also set the instance\'s is_dirty to True'
    )
    # - Test all permutations of "bad" argument-values:
    for value in BadStandardOptionalTextLines:
        try:
            test_object._set_building_address(value)
            # - If this setter-call succeeds, that's a 
            #   test-failure!
            self.fail(
                'Order._set_business_address should raise '
                'TypeError or ValueError if passed "%s" (%s), '
                'but it was allowed to be set instead.' % 
                (value, type(value).__name__)
            )
        except (TypeError, ValueError):
            # - This is expected, so it passes
            pass
        except Exception as error:
            self.fail(
                'Order._set_business_address should raise '
                'TypeError or ValueError if passed an invalid '
                'value, but %s was raised instead: %s.' % 
                (error.__class__.__name__, error)
            )

hms_artisan名称空间的所有测试的最终测试运行报告显示,除了显式跳过的七个测试外,所有测试都已运行,没有测试失败:

测试新的 hms_ 核心课程

在完成模块单元测试的常规设置过程(创建测试模块、执行测试模块、为报告缺失的每个项目创建测试用例类、执行测试模块以及为报告缺失的每个项目创建测试方法)之后,初步结果显示,与以前的单元测试模块相比,需要实施的测试要少得多,只有 11 个测试需要填充:

尽管如此,这些结果还是有一个警告:它们包括BaseDataObjectHMSMongoDataObject所要求的数据对象方法的测试,只是定义为所创建的ArtisanProduct类的一部分的属性和方法的测试。这些人生活在自己的测试模块中,增加了另外 33 个需要实施的测试:

单元测试 hms_core.data_storage.py

DatastoreConfig类的大部分测试遵循前面建立的测试模式。值得注意的例外是在测试其from_config类方法时,需要编写实际的配置文件来测试。但是,通过创建一个包含所有良好值的配置文件来测试所有良好值与其他测试方法没有太大区别,这些测试方法涉及从一个dict值创建一个对象实例——对所有良好测试值进行相同的迭代会启动它:

# - Test all permutations of "good" argument-values:
config_file = '/tmp/datastore-test.json'
for database in good_databases:
    for host in good_hosts:
        for password in good_passwords:
            for port in good_ports:
                for user in good_users:
                    config = {
                        'database':database,
                        'host':host,
                        'password':password,
                        'port':port,
                        'user':user,
                    }

这是创建临时配置文件的位置:

fp = open('/tmp/datastore-test.json', 'w')
json.dump(config, fp)
fp.close()

然后调用from_config,并执行各种断言:

test_object = DatastoreConfig.from_config(config_file)
self.assertEqual(test_object.database, database)
self.assertEqual(test_object.host, host)
self.assertEqual(test_object.password, password)
self.assertEqual(test_object.port, port)
self.assertEqual(test_object.user, user)
os.unlink(config_file)

类似的方法/结构用于测试每个参数/属性的各种错误值(databasehostpasswordportuser)。它们看起来都很像对坏数据库值的测试:

# - Test all permutations of each "bad" argument-value 
#   set against "good" values for the other arguments:
# - database
host = good_hosts[0]
password = good_passwords[0]
port = good_ports[0]
user = good_users[0]
for database in bad_databases:
    config = {
        'database':database,
        'host':host,
        'password':password,
        'port':port,
        'user':user,
    }
    fp = open('/tmp/datastore-test.json', 'w')
    json.dump(config, fp)
    fp.close()
    try:
        test_object = DatastoreConfig.from_config(config_file)
        self.fail(
            'DatastoreConfig.from_config should not '
            'accept "%s" (%s) as a valid database config-'
            'value, but it was allowed to create an '
            'instance' % (database, type(database).__name__)
        )
    except (RuntimeError, TypeError, ValueError):
        pass

HMSMongoDataObject的许多测试过程也与先前建立的测试写作模式类似:

  • 由于该类派生自BaseDataObject,因此有许多相同的必需测试方法依赖于实现的抽象功能,因此创建一个派生类进行测试,如果只是为了确保依赖方法调用成功的话

  • _create_update方法的测试基本上与hms_artisan对应方法测试时创建的测试相同,因为它们也简单地提出了NotImplementedError

Testing the functionality of any HMSMongoDataObject-derived class requires an operational MongoDB installation. Without one, the tests may raise errors (which would hopefully at least indicate what the problem is), or may just sit waiting for a connection to a MongoDB to resolve until the connection-effort times out.

由于本地属性都使用对其底层存储属性的实际删除,并且是惰性实例化的(如果它们还不可用,则在需要时创建),因此需要与以前的属性测试不同的方法。为了将所有相关测试代码保存在一个位置,跳过了test_del_方法,并将属性的删除方面的测试与test_get_方法合并。以test_get_connection为例:

def test_get_connection(self):
    # Tests the _get_connection method of the HMSMongoDataObject class
    # - Test that lazy instantiation on a new instance returns the 
    #   class-attribute value (_connection)
    test_object =  HMSMongoDataObjectDerived()
    self.assertEqual(
        test_object._get_connection(), 
        HMSMongoDataObjectDerived._connection
    )
    # - Test that deleting the current connection and re-aquiring it 
    #   works as expected
    test_object._del_connection()
    self.assertEqual(
        test_object._get_connection(), 
        HMSMongoDataObjectDerived._connection
    )
    # - There may be more to test later, but this suffices for now...

每种方法的过程类似:

  1. 创建一个test_object实例
  2. 断言被测试的属性 getter 在调用时返回公共类属性值(本例中为HMSMongoDataObjectDerived._connection
  3. 调用 deleter 方法
  4. 再次指定在调用 getter 时返回公共类属性值

在 deleter 和 getter 方法调用之间断言类属性值已被删除也是一个好主意,但只要最后的 getter 调用断言仍然通过,就没有必要这样做。

HMSMongoDataObject的测试用例类中有几个项目依赖于实际的数据库连接,以便在远程使用。此外,还有一些测试方法可以跳过,或者它们的实现值得注意,它们直接与依赖关系相关。因为我们需要数据库连接,所以每次运行测试用例类时都必须配置数据库连接。理想情况下,它不应该为每一个需要连接的测试都运行,但如果它运行了,那也没什么大不了的,至少目前为止不是在系统规模上,而是在更大规模的系统中,为每一个需要连接的测试方法创建一个新的数据库可能会减慢速度。可能是实质性的。

幸运的是,标准 Pythonunittest模块提供了一些方法,可以用来初始化数据库连接数据,并在所有测试完成后删除用于测试的数据库。这些分别是setUptearDown方法。setUp只需配置数据访问,因为HMSMongoDataObjects将在需要时负责创建其所需的connectiondatabasecollection对象:

def setUp(self):
    # - Since we need a database to test certain methods, 
    #   create one here
    HMSMongoDataObject.configure(self.__class__.config)

tearDown负责完全删除将为测试用例类创建的测试数据库,只需创建一个MongoClient,然后使用它删除配置中指定的数据库:

def tearDown(self):
    # - delete the database after we're done with it, so that we 
    #   don't have data persisting that could bollix up subsequent 
    #   test-runs
    from pymongo import MongoClient
    client = MongoClient()
    client.drop_database(self.__class__.config.database)

如果我们试图断言任何预期值或行为,setUptearDown方法的行为方式将与典型测试方法的行为方式不同——任何失败的断言都会引发错误。这意味着,尽管我们可以断言配置已经准确完成,但从报告的角度来看,它并没有真正起到任何作用。在这种情况下,如果配置调用没有引发任何错误,并且依赖于它的各种测试方法都通过了,则可以将其视为配置正在执行预期操作的证据。在这种情况下,我们可以跳过相关的测试方法:

@unittest.skip(
    'The fact that the configuration works in setUp is sufficient'
)
def test_get_configuration(self):
    # Tests the _get_configuration method of the HMSMongoDataObject class
    # - Test all permutations of "good" argument-values:
    # - Test all permutations of each "bad" argument-value 
    #   set against "good" values for the other arguments:
    self.fail('test_get_configuration is not yet implemented')

@unittest.skip(
    'The fact that the configuration works in setUp is sufficient'
)
def testconfigure(self):
    # Tests the configure method of the HMSMongoDataObject class
    self.fail('testconfigure is not yet implemented')

为了全面测试deletegetsave方法,我们必须实现一个一次性派生类—HMSMongoDataObjectDerived

class HMSMongoDataObjectDerived(HMSMongoDataObject):

    _data_dict_keys = (
        'name', 'description', 'cost', 'oid', 'created', 'modified', 
        'is_active', 'is_deleted'
    )

特别是,我们需要一些可用于测试get的本地属性,但它们只需要是在初始化过程中设置的、出现在to_data_dict调用结果中的简单属性:

def __init__(self, name=None, description=None, cost=0, 
    oid=None, created=None, modified=None, is_active=None, 
    is_deleted=None, is_dirty=None, is_new=None
  ):
    HMSMongoDataObject.__init__(
    self, oid, created, modified, is_active, is_deleted, 
    is_dirty, is_new
  )
    self.name = name
    self.description = description
    self.cost = cost

def to_data_dict(self):
    return {
         # - "local" properties
         'name':self.name,
         'description':self.description,
         'cost':self.cost,
         # - standard items from HMSMongoDataObject/BaseDataObject
         'created':self.created.strftime(self.__class__._data_time_string),
         'is_active':self.is_active,
         'is_deleted':self.is_deleted,
         'modified':self.modified.strftime(self.__class__._data_time_string),
         'oid':str(self.oid),
        }

def matches(self, **criteria):
    return HMSMongoDataObject.matches(self, **criteria)

为了测试delete方法,我们需要首先创建并保存一些对象:

def testdelete(self):
    # Tests the delete method of the HMSMongoDataObject class
    # - In order to really test get, we need some objects to test 
    #   against, so create a couple dozen:
    names = ['Alice', 'Bob', 'Carl', 'Doug']
    costs = [1, 2, 3]
    descriptions = [None, 'Description']
    all_oids = []
    for name in names:
        for description in descriptions:
            for cost in costs:
                item = HMSMongoDataObjectDerived(
                    name=name, description=description, cost=cost
                )
                item.save()
                all_oids.append(item.oid)

我们想测试一下,我们可以删除多个项目,也可以删除单个项目,所以我们将获取所创建对象集合的最后一半,删除这些项目,然后获取剩余项目的最后一半,依此类推,直到我们只剩下一个对象。在每次迭代中,我们删除oid的当前集合,并在删除后验证它们是否不存在。最后,我们验证是否已删除所有创建的对象:

# - Delete varying-sized sets of items by oid, and verify that 
#   the deleted oids are gone afterwards...
while all_oids:
     try:
        oids = all_oids[len(all_oids)/2:]
        all_oids = [o for o in all_oids if o not in oids]
     except:
        oids = all_oids
        all_oids = []
     HMSMongoDataObjectDerived.delete(*oids)
     items = HMSMongoDataObjectDerived.get(*oids)
     self.assertEqual(len(items), 0)
# - Verify that *no* items exist after they've all been deleted
items = HMSMongoDataObjectDerived.get()
self.assertEqual(items, [])

测试get也采用了类似的方法——创建几个具有易于识别的属性值的项目,这些属性值可用作criteria

def testget(self):
   # Tests the get method of the HMSMongoDataObject class
   # - In order to really test get, we need some objects to test 
   #   against, so create a couple dozen:
   names = ['Alice', 'Bob', 'Carl', 'Doug']
   costs = [1, 2, 3]
   descriptions = [None, 'Description']
   for name in names:
      for description in descriptions:
         for cost in costs:
             HMSMongoDataObjectDerived(
                  name=name, description=description, cost=cost
             ).save()

然后我们可以迭代这些相同的值,创建一个要使用的criteria集,并验证返回的对象是否具有我们传递的criteria值。首先是一个criteria值:

# - Now we should be able to try various permutations of get 
#   and get verifiable results. These tests will fail if the 
#   _data_dict_keys class-attribute isn't accurate...
for name in names:
    criteria = {
        'name':name,
    }
    items = HMSMongoDataObjectDerived.get(**criteria)
    actual = len(items)
    expected = len(costs) * len(descriptions)
    self.assertEqual(actual, expected, 
        'Expected %d items returned (all matching name="%s"), '
        'but %d were returned' % 
        (expected, name, actual)
    )
    for item in items:
        self.assertEqual(item.name, name)

然后我们使用多个criteria进行测试,以确保多个criteria值的行为符合预期:

for cost in costs:
    criteria = {
         'name':name,
         'cost':cost,
    }
    items = HMSMongoDataObjectDerived.get(**criteria)
    actual = len(items)
    expected = len(descriptions)
    self.assertEqual(actual, expected, 
         'Expected %d items returned (all matching '
         'name="%s" and cost=%d), but %d were returned' % 
         (expected, name, cost, actual)
   )
   for item in items:
       self.assertEqual(item.name, name)
       self.assertEqual(item.cost, cost)

deleteget方法的测试之间,我们已经有效地测试了save方法——毕竟,我们必须保存对象以获取或删除它们——因此testsave可以说并不是真的需要。为了得到一个实际的测试,而不是另一个跳过测试的条目,我们无论如何都要实现它,并使用它来测试我们也可以通过它的oid值获得一个对象:

# - Noteworthy because save/get rather than save/pymongo-query.
#   another option would be to do a "real" pymongo query, but that 
#   test-code would look like the code in get anyway...?
def testsave(self):
   # Tests the save method of the HMSMongoDataObject class
   # - Testing save without using get is somewhat cumbersome, and 
   #   perhaps too simple...?
   test_object = HMSMongoDataObjectDerived()
   test_object.save()
   expected = test_object.to_data_dict()
   results = HMSMongoDataObjectDerived.get(str(test_object.oid))
   actual = results[0].to_data_dict()
   self.assertEqual(actual, expected)

最终的测试输出,一旦所有内容都实现并通过,将显示 47 个测试,其中五个被跳过:

单元测试 hms_core.co_objects.py

co_objects中的ArtisanProduct类与hms_artisanartisan_objects模块中的对应类一样,必须被重写,以便在状态数据记录中的任何属性发生更改时提供适当的is_dirty行为。因此,他们必须创建相应的测试方法,就像在hms_artisan包中测试他们对应的测试方法一样。实际上,在这两个模块中进行了相同的更改,因此,两个包中存在的类的测试类和其中的测试方法是相同的。

单元测试与信任

前面已经提到,单元测试代码的真正目的是确保代码在所有可能的执行情况下都以可预测的方式运行。实际上,它还涉及在代码库中建立信任度量。在这种情况下,必须划出一条线,即在哪里可以简单地将这种信任视为一种给定的信任。例如,本次迭代中的各种单元测试都集中于确保为数据持久性创建的代码能够从数据库引擎中获得所有必需的东西。它并不关心连接到数据库引擎的库是否可信;出于我们的目的,我们假设它是,至少在我们遇到无法以任何其他方式解释的测试失败之前。

单元测试为可能使用我们的代码的其他人提供了信任,因为他们知道需要测试的所有内容都已完成,并且所有测试都已通过。

建造/分配、演示和验收

各个模块的构建过程不会有太大的变化,尽管现在有了单元测试,这些测试可以添加到用于打包各个 Python 包的setup.py文件中。已经存在的setup函数可以通过提供指向根测试套件目录的test_suite参数来执行整个测试套件,只需进行最小的更改。

可能需要确保测试套件目录的路径也已添加到sys.path中:

#!/usr/bin/env python

import sys
sys.path.append('../standards')
sys.path.append('tests/test_hms_core') # <-- This path

那么,当前的setup函数调用包括如下的test_suite

setup(
    name='HMS-Core',
    version='0.1.dev0',
    author='Brian D. Allbee',
    description='',
    package_dir={
        '':'src',
    },
    packages=[
        'hms_core',
    ],
    test_suite='tests.test_hms_core',
)

然后可以使用python setup.py test执行整个测试套件,它返回已执行测试及其结果的逐行摘要:

在组件项目中打包代码仍然使用单个项目目录中的python setup.py sdist,并且仍然生成可安装的包:

演示新的数据持久性功能可以通过多种方式完成,但需要在一次性/临时数据库中创建一次性/临时演示数据对象。test_co_objects测试模块中的代码就是这样做的,因此基于该结构创建一个最小的数据对象类(出于演示目的称之为ExampleObject,然后运行:

HMSMongoDataObject.configure(
    DatastoreConfig(database='demo_data')
)

print('Creating data-objects to demo with')
names = ['Alice', 'Bob', 'Carl', 'Doug']
costs = [1, 2, 3]
descriptions = [None, 'Description']
for name in names:
    for description in descriptions:
        for cost in costs:
            item = ExampleObject(
                name=name, description=description, cost=cost
            )
            item.save()

它负责生成可以检查的数据集。从这一点上讲,任何工具——命令行mongo客户端或 GUI,如 Robo3T——都可以用来查看和验证数据是否确实持久化:

如果需要更详细的验收示例(例如每种业务对象类型的示例),可以编写类似的脚本来创建ArtisanProduct实例并保存它们。类似地,对于hms_artisan数据对象类,只需在示例/演示环境中显示为对象编写的文件就足够了。

操作/使用、维护和退役注意事项

就这些项目而言,尚未发生实质性变化:

  • 虽然现在有三个软件包,但仍然非常简单。
  • 尽管我们已经在pymongo库中添加了一个外部依赖项,但我们还不需要担心如何处理该依赖项。
  • 显然需要安装 MongoDB,但在代码准备好集成到某个共享环境之前,即使这不是问题,本地开发现在也可以使用本地数据库引擎。
  • 从退役的角度来看,卸载软件并没有真正改变,只是现在有三个软件包需要卸载——但每个软件包的卸载过程都是上一次迭代结束时的过程的变体(pip uninstall HMS-Core

总结

虽然在以后的迭代中可能会有其他数据访问和数据持久性调整,并且由于与其他系统的集成问题,有一些数据对象的具体细节尚不清楚,但大部分数据对象的工作已经完成

到目前为止,针对hms_sys代码库的开发迭代的大部分注意力都集中在可能被认为是系统功能的东西上——确保数据结构格式良好,可以验证,并且比单用户会话或 Python 运行寿命更长。从用户的角度与系统数据的交互还没有得到解决。不过,在解决这一问题之前,至少还需要对另一层进行分析(如果不是构建的话)——Artisan 网关服务,它是远程 Artisan 和中央办公室工作人员的数据汇集的中心点。