本章将专门关注BaseDataObjectABC(抽象基类)的开发和测试,我们在hms_artisan(Artisan 应用程序)和hms_gateway(Artisan 网关服务)组件项目中都需要它。hms_co(中央办公室应用程序代码库也可能需要使用相同的功能。稍后,我们将在处理hms_co代码时深入了解这一点。
目前,我们期望BaseDataObject看起来像这样:
驱动前面描述的BaseDataObject设计和实现的故事如下:
- 作为一名开发人员,我需要一个通用的结构来为整个系统中可用的业务对象提供状态数据的持久性,以便构建相关的最终类
BaseDataObject在功能上与hms_core中的业务对象定义无关,但它提供的功能仍然需要对所有真正的代码库可用——应用程序和Artisan Gateway服务的代码库——它应该存在于hms_core包中,但可能不是使用上一次迭代中的业务对象定义。从长远来看,如果将hms_core包的各个成员组织成模块,将元素组合成共同的目的或主题,那么hms_core包将更容易理解和维护。在本次迭代结束之前,当前的hms_core.__init__.py模块将更名为更能说明其服务目的的模块,它将位于包含所有数据对象类和功能的新模块旁边:data_object.py。
另外还有两个故事与BaseDataObject的结构和能力有关,在课程开发过程中满足了这两个故事的需要,我们会注意到这两个故事:
- 作为任何数据使用者,我需要能够创建、读取、更新和删除单个数据对象,以便能够对这些对象执行基本的数据管理任务。
- 作为任何数据消费者,我需要能够搜索特定的数据对象,以便能够处理找到的结果项。
BaseDataObject的大部分属性是布尔值,即指示类实例是否处于特定状态的标志。这些属性的实现都遵循一个简单的模式,在上一次迭代中,BaseProduct的available属性的定义中已经显示了这个模式。该结构如下所示:
###################################
# Property-getter methods #
###################################
def _get_bool_prop(self) -> (bool,):
return self._bool_prop
###################################
# Property-setter methods #
###################################
def _set_bool_prop(self, value:(bool,int)):
if value not in (True, False, 1, 0):
raise ValueError(
'%s.bool_prop expects either a boolean value '
'(True|False) or a direct int-value equivalent '
'(1|0), but was passed "%s" (%s)' %
(self.__class__.__name__, value, type(value).__name__)
)
if value:
self._bool_prop = True
else:
self._bool_prop = False
###################################
# Property-deleter methods #
###################################
def _del_bool_prop(self) -> None:
self._bool_prop = False
###################################
# Instance property definitions #
###################################
bool_prop = property(
_get_bool_prop, _set_bool_prop, _del_bool_prop,
'Gets sets or deletes the flag that indicates whether '
'the instance is in a particular state'
)这些属性后面的 deleter 方法在初始化期间也用于设置实例的默认值,因此在删除属性时(调用这些方法)应产生特定值:
###################################
# Property-deleter methods #
###################################
def _del_is_active(self) -> None:
self._is_active = True
def _del_is_deleted(self) -> None:
self._is_deleted = False
def _del_is_dirty(self) -> None:
self._is_dirty = False
def _del_is_new(self) -> None:
self._is_new = True除非由派生类或特定对象创建过程重写,否则从BaseDataObject派生的任何实例都将以以下内容开头:
is_active == Trueis_deleted == Falseis_dirty == Falseis_new == True
因此,新创建的实例将是活动的、未删除的、未脏的和新的,假设创建新对象的过程通常是为了保存新的活动对象。如果在创建实例之间发生任何状态更改,则在过程中可能会将is_dirty标志设置为True,但is_new为True的事实意味着需要在后端数据存储中创建对象的记录,而不是更新。
与标准布尔属性结构的唯一显著差异在于属性定义期间的文档记录:
###################################
# Instance property definitions #
###################################
is_active = property(
_get_is_active, _set_is_active, _del_is_active,
'Gets sets or deletes the flag that indicates whether '
'the instance is considered active/available'
)
is_deleted = property(
_get_is_deleted, _set_is_deleted, _del_is_deleted,
'Gets sets or deletes the flag that indicates whether '
'the instance is considered to be "deleted," and thus '
'not generally available'
)
is_dirty = property(
_get_is_dirty, _set_is_dirty, _del_is_dirty,
'Gets sets or deletes the flag that indicates whether '
'the instance\'s state-data has been changed such that '
'its record needs to be updated'
)
is_new = property(
_get_is_new, _set_is_new, _del_is_new,
'Gets sets or deletes the flag that indicates whether '
'the instance needs to have a state-data record created'
)BaseDataObject的两个属性created和modified在类图中显示为datetime值–表示特定日期的特定时间的对象。datetime对象存储日期/时间的年、月、日、小时、分钟、秒和微秒,并提供了多种便利,例如,使用严格作为时间戳数字值或日期/时间的字符串表示形式管理的等效值。其中一个方便之处是能够从字符串中解析值,从而允许属性后面的_set_created和_set_modifiedsetter 方法接受字符串值,而不需要实际值datetime。类似地,datetime提供了根据时间戳创建datetime实例的能力–从公共开始日期/时间经过的秒数。为了完全支持所有这些参数类型,有必要定义一个公共格式字符串,用于解析datetime从字符串中获取值,并将其格式化为字符串。至少目前为止,该值似乎最好作为类属性存储在BaseDataObject本身。这样,从该值派生的所有类在默认情况下都具有相同的可用值:
class BaseDataObject(metaclass=abc.ABCMeta):
"""
Provides baseline functionality, interface requirements, and
type-identity for objects that can persist their state-data in
any of several back-end data-stores.
"""
###################################
# Class attributes/constants #
###################################
_data_time_string = '%Y-%m-%d %H:%M:%S'setter 方法比大多数方法要长一些,因为它们处理四种不同的可行值类型,尽管只需要两个子流程来覆盖所有这些变化。setter 流程首先对提供的值进行类型检查,并确认它是可接受的类型之一:
def _set_created(self, value:(datetime,str,float,int)):
if type(value) not in (datetime,str,float,int):
raise TypeError(
'%s.created expects a datetime value, a numeric '
'value (float or int) that can be converted to '
'one, or a string value of the format "%s" that '
'can be parsed into one, but was passed '
'"%s" (%s)' %
(
self.__class__.__name__,
self.__class__._data_time_string, value,
type(value).__name__,
)
)处理任何一种合法的数字类型都相当简单。如果检测到错误,我们应该围绕遇到的问题的性质提供更具体的消息:
if type(value) in (int, float):
# - A numeric value was passed, so create a new
# value from it
try:
value = datetime.fromtimestamp(value)
except Exception as error:
raise ValueError(
'%s.created could not create a valid datetime '
'object from the value provided, "%s" (%s) due '
'to an error - %s: %s' %
(
self.__class__.__name__, value,
type(value).__name__,
error.__class__.__name__, error
)
)处理字符串值的子流程类似,除了调用datetime.strptime而不是datetime.fromtimestamp,以及使用_data_time_string类属性定义有效日期/时间字符串的外观外:
elif type(value) == str:
# - A string value was passed, so create a new value
# by parsing it with the standard format
try:
value = datetime.strptime(
value, self.__class__._data_time_string
)
except Exception as error:
raise ValueError(
'%s.created could not parse a valid datetime '
'object using "%s" from the value provided, '
'"%s" (%s) due to an error - %s: %s' %
(
self.__class__.__name__,
self.__class__._data_time_string,
value, type(value).__name__,
error.__class__.__name__, error
)
)如果原始值是datetime的实例,则之前的两个子流程都不会执行。如果执行了其中一个,那么原始值参数将被替换为一个datetime实例。无论哪种情况,该值都可以存储在基础属性中:
# - If this point is reached without error,then we have a
# well-formed datetime object, so store it
self._created = value出于BaseDataObject的目的,created和modified都应该始终有一个值,如果在需要时一个值不可用——通常只有在保存数据对象的状态数据记录时才可用——那么应该为当前值创建一个值,这可以通过使用datetime.now()的 getter 方法实现:
def _get_created(self) -> datetime:
if self._created == None:
self.created = datetime.now()
return self._created这反过来意味着 deleter 方法应该将属性存储属性的值设置为None:
def _del_created(self) -> None:
self._created = None对应的属性定义是标准的,created属性不允许直接删除;允许对象删除其自己创建的日期/时间是没有意义的:
###################################
# Instance property definitions #
###################################
created = property(
_get_created, _set_created, None,
'Gets, sets or deletes the date-time that the state-data '
'record of the instance was created'
)
# ...
modified = property(
_get_modified, _set_modified, _del_modified,
'Gets, sets or deletes the date-time that the state-data '
'record of the instance was last modified'
)BaseDataObject的最后一个属性可能是最关键的oid,用于唯一标识给定数据对象的状态数据记录。该属性定义为 Python 在其uuid库中提供的通用唯一标识符(UUID值。使用 UUID 作为唯一标识符,而不是使用一些更传统的方法(如序列记录号),至少有两个优点:
-
**UUID 不依赖于数据库操作的成功与否:**它们可以在代码中生成,而不必担心等待 SQL 插入完成,或者 NoSQL 数据存储中可能存在的任何相应机制。这意味着更少的数据库操作,也可能更简单,这使事情变得更容易。
-
**UUID 不容易预测:**UUID 是由 32 个十六进制数字组成的一系列数字(有些破折号将它们分隔为与本讨论无关的部分),例如
ad6e3d5c-46cb-4547-9971-5627e6b3039a。如果它们是由uuid库提供的几个标准函数中的任何一个生成的,那么它们的序列,如果不是真正随机的,至少随机到足以让恶意用户很难找到给定的值,可能需要寻找 3.4×1034个值(每个十六进制数字 16 个值,31 个数字,因为保留了一个)。
The unpredictability of UUIDs is especially useful in applications that have data accessible over the internet. Identification of records by sequential numbering makes it much easier for malicious processes to hit an API of some sort and just retrieve each record in sequence, all else being equal.
不过,有一些警告:
- 并非所有数据库引擎都将 UUID 对象识别为可行的字段类型。这可以通过在数据对象中存储实际的 UUID 值来管理,但要在数据库中写入和读取这些值的字符串表示形式。
- 使用 UUID 作为唯一标识符的数据库操作可能会受到非常轻微的性能影响,特别是在使用字符串表示而不是实际值的情况下。
- 如果没有其他可使用的识别标准——可以根据(其他识别标准)查询的人类有意义的数据值,那么它们固有的不可预测性可能会使数据的合法检查变得困难。
即使撇开优势不谈,BaseDataObject也将使用 UUID 进行对象标识(即oid属性),因为这是需求和预期实现的组合:
-
Artisan 应用程序背后不会有真正的数据库。它可能最终成为一个简单的本地文档存储,因此为任何给定数据对象生成唯一标识符必须是自包含的,并且不依赖于应用程序代码库以外的任何东西。
-
相同的
oid值需要在Artisan 应用程序和Artisan 网关服务之间传播。试图跨任意数量的工匠协调身份可能会很快导致身份冲突,并且缓解这可能需要更多的工作(可能更多),而不需要对系统的需求进行重大更改,或者至少不需要对系统中的各种可安装件进行交互。任意两个随机生成的 UUID 之间发生冲突的可能性极低(如果不是所有实际目的都不可能的话),这仅仅是因为可能涉及的值的数量。
oid属性的实现将遵循与基于datetime属性的实现类似的模式。getter 方法将根据需要创建一个,setter 方法将接受UUID对象或其字符串表示,并在内部创建实际的UUID对象,deleter 方法将当前存储值设置为None:
def _get_oid(self) -> UUID:
if self._oid == None:
self._oid = uuid4()
return self._oid
# ...
def _set_oid(self, value:(UUID,str)):
if type(value) not in (UUID,str):
raise TypeError(
'%s.oid expects a UUID value, or string '
'representation of one, but was passed "%s" (%s)' %
(self.__class__.__name__, value, type(value).__name__)
)
if type(value) == str:
try:
value = UUID(value)
except Exception as error:
raise ValueError(
'%s.oid could not create a valid UUID from '
'the provided string "%s" because of an error '
'%s: %s' %
(
self.__class__.__name__, value,
error.__class__.__name__, error
)
)
self._oid = value
# ...
def _del_oid(self) -> None:
self._oid = NoneBaseDataObject的大多数方法都是抽象的,包括所有的类方法。它们都没有任何可以在派生类中重用的具体实现,因此它们都是非常基本的定义:
###################################
# Abstract methods #
###################################
@abc.abstractmethod
def _create(self) -> None:
"""
Creates a new state-data record for the instance in the back-end
data-store
"""
raise NotImplementedError(
'%s has not implemented _create, as required by '
'BaseDataObject' % (self.__class__.__name__)
)
@abc.abstractmethod
def to_data_dict(self) -> (dict,):
"""
Returns a dictionary representation of the instance which can
be used to generate data-store records, or for criteria-matching
with the matches method.
"""
raise NotImplementedError(
'%s has not implemented _create, as required by '
'BaseDataObject' % (self.__class__.__name__)
)
@abc.abstractmethod
def _update(self) -> None:
"""
Updates an existing state-data record for the instance in the
back-end data-store
"""
raise NotImplementedError(
'%s has not implemented _update, as required by '
'BaseDataObject' % (self.__class__.__name__)
)
###################################
# Class methods #
###################################
@abc.abstractclassmethod
def delete(cls, *oids):
"""
Performs an ACTUAL record deletion from the back-end data-store
of all records whose unique identifiers have been provided
"""
raise NotImplementedError(
'%s.delete (a class method) has not been implemented, '
'as required by BaseDataObject' % (cls.__name__)
)
@abc.abstractclassmethod
def from_data_dict(cls, data_dict:(dict,)):
"""
Creates and returns an instance of the class whose state-data has
been populate with values from the provided data_dict
"""
raise NotImplementedError(
'%s.from_data_dict (a class method) has not been '
'implemented, as required by BaseDataObject' %
(cls.__name__)
)
@abc.abstractclassmethod
def get(cls, *oids, **criteria):
"""
Finds and returns all instances of the class from the back-end
data-store whose oids are provided and/or that match the supplied
criteria
"""
raise NotImplementedError(
'%s.get (a class method) has not been implemented, '
'as required by BaseDataObject' % (cls.__name__)
)to_data_dict实例方法和from_data_dict类方法旨在提供将实例的完整状态数据表示为dict的机制,并分别从这种dict表示中创建实例。from_data_dict方法应该有助于在 Python 中的大多数标准 RDBMS 连接库中检索记录并将其转换为实际的编程对象,特别是当数据库中的字段名与类的属性名相同时。类似的用法在 NoSQL 数据存储中也应该是可行的。尽管to_data_dict方法在将记录写入数据存储时可能有用,也可能不有用,但需要根据条件匹配对象(matches 方法,我们稍后将介绍)。
PEP-249, the current Python Database API Specification, defines an expectation that database queries in libraries that conform to the standards of the PEP will, at a minimum, return lists of tuples as result sets. Most mature database connector libraries also provide a convenience mechanism to return a list of dict record values, where each dict maps field names as keys to the values of the source records.
_create和_update方法只是记录创建和记录更新过程的需求,最终将由save方法调用。但是,对单独的记录创建和记录更新过程的需求可能不适用于所有数据存储引擎;有些,特别是在 NoSQL 领域,已经提供了一种编写记录的机制,根本不关心它是否已经存在。其他人可能会提供某种机制,允许首先尝试创建一个新记录,如果失败(因为找到了一个重复的键,表明该记录已经存在),则更新现有记录。此选项在MySQL和MariaDB数据库中可用,但可能存在于其他地方。在任何情况下,重写 save 方法以使用这些单点接触流程都可能是更好的选择。
delete类方法是自解释的,sort可能也是。
get方法需要一些检查,即使没有任何具体实施。如前所述,它是用于返回具有从数据库检索到的状态数据的对象的主要机制,并接受零到多的对象 ID(“T1”参数列表)和过滤条件(“T2”关键字参数中)。对整个get流程实际工作的预期如下:
-
如果
oids不为空:- 执行所需的任何低级查询或查找,以查找与提供的
oids之一匹配的对象,用from_data_dict处理每条记录并生成对象列表 - 如果
criteria不为空,则将当前列表向下过滤到matches结果与条件不符的对象为True - 返回结果列表
- 执行所需的任何低级查询或查找,以查找与提供的
-
否则,如果
criteria不为空:- 执行所需的任何低级查询或查找,以查找与提供的条件值之一匹配的对象,使用
from_data_dict处理每条记录并生成对象列表 - 将当前列表向下过滤到那些根据条件得到
matches结果为True的对象 - 返回结果列表
- 执行所需的任何低级查询或查找,以查找与提供的条件值之一匹配的对象,使用
-
否则,执行检索所有可用对象所需的任何低级查询或查找,再次使用
from_data_dict处理每条记录,生成对象列表并简单地返回所有对象
总之,oids和criteria值的组合将允许get类方法查找并返回执行以下操作的对象:
- 匹配一个或多个
oids:get(oid[, oid, …, oid]) - 匹配一个或多个
oids和某组criteria:get(oid[, oid, …, oid], key=value[, key=value, …, key=value]) - 匹配一个或多个
criteria键/值对,而不管找到的项目oids:get(key=value[, key=value, …, key=value]) - 只存在于后端数据存储中:
get()
这就剩下了类中仅有的两个具体实现matches和save方法。matches背后的目标是提供一种实例级机制,用于将实例与条件名称/值进行比较,这是get方法中的criteria实际查找匹配项所使用和依赖的过程。它的实现比一开始看起来要简单,但依赖于对set对象的操作,以及经常被忽略的 Python 内置函数(all,因此在代码中对流程本身进行了大量注释:
###################################
# Instance methods #
###################################
def matches(self, **criteria) -> (bool,):
"""
Compares the supplied criteria with the state-data values of
the instance, and returns True if all instance properties
specified in the criteria exist and equal the values supplied.
"""
# - First, if criteria is empty, we can save some time
# and simply return True - If no criteria are specified,
# then the object is considered to match the criteria.
if not criteria:
return True
# - Next, we need to check to see if all the criteria
# specified even exist in the instance:
data_dict = self.to_data_dict()
data_keys = set(check_dict.keys())
criteria_keys = set(criteria.keys())
# - If all criteria_keys exist in data_keys, then the
# intersection of the two will equal criteria_keys.
# If that's not the case, at least one key-value won't
# match (because it doesn't exist), so return False
if criteria_keys.intersection(data_keys) != criteria_keys:
return False
# - Next, we need to verify that values match for all
# specified criteria
return all(
[
(data_dict[key] == criteria[key])
for key in criteria_keys
]
)all函数非常方便,如果传递给它的 iterable 中的所有项都计算为True(或者至少为 true ish,因此非空字符串、列表、元组和字典以及非零数字都将被视为True,它将返回True。如果 iterable 的任何成员不是True,则返回False,如果 iterable 为空,则返回True。如果出现以下情况,matches的结果将为False:
- 实例的
data_dict中不存在criteria中的任何键–本质上是一个无法匹配的条件键 criteria中指定的任何值与实例data_dict中对应的值不完全匹配
save方法非常简单。它只是根据实例的is_new或is_dirty标志属性的当前状态分别调用实例的_create或_update方法,并在其中一个执行后重置这些标志,使对象保持干净,为下一步可能发生的事情做好准备:
def save(self):
"""
Saves the instance's state-data to the back-end data-store by
creating it if the instance is new, or updating it if the
instance is dirty
"""
if self.is_new:
self._create()
self._set_is_new = False
self._set_is_dirty = False elif self.is_dirty:
self._update()
self._set_is_dirty = False
self._set_is_new = FalseBaseDataObject的初始化应允许其所有属性的值,但不需要这些值中的任何一个:
def __init__(self,
oid:(UUID,str,None)=None,
created:(datetime,str,float,int,None)=None,
modified:(datetime,str,float,int,None)=None,
is_active:(bool,int,None)=None,
is_deleted:(bool,int,None)=None,
is_dirty:(bool,int,None)=None,
is_new:(bool,int,None)=None,
):在这种情况下,实际的初始化过程遵循之前为所有参数建立的可选参数模式:为每个参数调用相应的_del_方法,如果参数不是None,则为每个参数调用相应的_set_方法。我们以oid参数为例:
# - Call parent initializers if needed
# - Set default instance property-values using _del_... methods
# ...
self._del_oid()
# - Set instance property-values from arguments using
# _set_... methods
if oid != None:
self._set_oid(oid)
# ...
# - Perform any other initialization needed这个初始值设定方法的签名越来越长,有七个参数(忽略self,因为它将始终存在,并且始终是第一个参数)。知道我们最终将把具体类定义为BaseDataObject和已定义的一个业务对象类的组合,这些具体类上__init__的签名也可能会更长。不过,这也是为什么BaseDataObject的初始化签名使得所有参数都是可选的部分原因。与其中一个业务对象类BaseArtisan结合使用,例如,__init__签名为:
def __init__(self,
contact_name:str, contact_email:str,
address:Address, company_name:str=None,
website:(str,)=None,
*products
):源于两者的Artisan的组合__init__签名,而长。。。
def __init__(self,
contact_name:str, contact_email:str,
address:Address, company_name:str=None,
website:(str,)=None,
oid:(UUID,str,None)=None,
created:(datetime,str,float,int,None)=None,
modified:(datetime,str,float,int,None)=None,
is_active:(bool,int,None)=None,
is_deleted:(bool,int,None)=None,
is_dirty:(bool,int,None)=None,
is_new:(bool,int,None)=None,
*products
):... 只需要BaseArtisan需要的contact_name、contact_email和address参数,并允许将所有参数作为关键字参数传递,如下所示:
artisan = Artisan(
contact_name='John Doe', contact_email='john@doe.com',
address=my_address, oid='00000000-0000-0000-0000-000000000000',
created='2001-01-01 12:34:56', modified='2001-01-01 12:34:56'
)允许将整个参数集定义为单个字典,并使用传递关键字参数集所使用的相同语法将整个 cloth 传递给初始值设定项:
artisan_parameters = {
'contact_name':'John Doe',
'contact_email':'john@doe.com',
'address':my_address,
'oid':'00000000-0000-0000-0000-000000000000',
'created':'2001-01-01 12:34:56',
'modified':'2001-01-01 12:34:56'
}
artisan = Artisan(**artisan_parameters)That syntax for passing arguments in a dictionary using **dictionary_name is a common form of argument parameterization in Python, especially in functions and methods where the full collection of arguments is unreasonably long. It requires some thought and discipline on the design side of the development process, and an eye toward being very restrictive with respect to required arguments, but in the long run, it's more helpful and easier to use than might appear at first glance.
最后一个结构在实现源自BaseDataObject的各种类的from_data_dict方法时至关重要——在大多数情况下,它应该允许这些方法的实现略多于此:
@classmethod
def from_data_dict(cls, data_dict):
return cls(**data_dict)BaseDataObject的单元测试将是……有趣的,就目前而言。测试matches方法,这是一种依赖于抽象方法(to_data_dict)的具体方法,而抽象方法又依赖于派生类的实际数据结构(properties),在BaseDataObject本身的测试用例类的上下文中,这既不可能,也没有意义:
- 为了测试
matches,我们必须定义一个具有to_data_dict具体实现的非抽象类,以及一些实际属性,以从/使用生成dict - 该派生类,除非它也是系统中需要的实际类,在最终的系统代码中没有相关性,因此那里的测试不能保证其他派生类在
matches中不会有问题 - 即使将
matches方法的测试完全放在一边,测试save也同样毫无意义,因为同样的原因,它是一种具体的方法,依赖于BaseDataObject级别的抽象和未定义的方法
当BaseArtisan被实现时,我们将其add_product和remove_product方法定义为抽象方法,但仍然在这两种方法中编写了可用的具体实现代码,以便允许派生类简单地调用父类的实现。实际上,我们需要在所有派生类中实现这两者,但提供了一个可以从派生类方法中调用的实现。同样的方法适用于BaseDataObject中的matches和save方法,基本上对每个派生的具体类强制执行测试要求,同时仍然允许使用单个实现,直到或除非需要覆盖该实现。这可能会让人感觉有点不舒服,但这种方法似乎没有任何缺点:
- 以这种方式处理的方法仍然必须在派生类中实现。
- 如果出于任何原因需要覆盖它们,测试策略仍将要求对它们进行测试。
- 如果它们被实现为对父类方法的调用,那么它们将起作用,并且测试策略代码仍然会将它们识别为派生类的本地类。我们的测试策略说,这些需要测试方法,并且允许测试方法根据派生类的特定需求和功能执行。
然而,测试save不必采用这种方法。最终,就这个方法而言,我们真正关心的是,我们可以证明它调用了_create和_update抽象方法并重置了标志。如果可以在测试BaseDataObject的过程中测试并建立该证明,我们就不必在其他地方测试它,除非测试策略代码检测到该方法的覆盖。这反过来又允许我们避免以后在所有最终的具体类的所有测试用例中分散相同的测试代码,这是一件好事。
启动data_objects模块的单元测试非常简单:
- 在项目的
test_hms_core目录中创建一个test_data_object.py文件 - 执行标题注释中注明的两个名称替换
- 在同一目录的
__init__.py中添加对它的引用 - 运行测试代码并完成正常的迭代测试编写过程
__init__.py中对新测试模块的引用遵循我们单元测试模块模板中已经存在的结构,复制现有代码中以# import child_module开头的两行代码,然后取消注释并将child_module更改为新测试模块:
#######################################
# Child-module test-cases to execute #
#######################################
import test_data_objects
LocalSuite.addTests(test_data_objects.LocalSuite._tests)
# import child_module
# LocalSuite.addTests(child_module.LocalSuite._tests)该添加将新test_data_objects模块中的所有测试添加到顶层__init__.py测试模块中已经存在的测试中,从而允许顶层测试套件执行子模块测试:
test_data_objects.py中的测试也可以独立执行,产生相同的故障,但不执行所有其他现有测试:
为data_objects.py编写单元测试的迭代过程与上一次迭代中为基础业务对象编写测试的过程没有什么不同:运行测试模块,找到失败的测试,编写或修改该测试,然后重新运行,直到所有测试通过。由于BaseDataObject是一个抽象类,因此需要一个一次性派生的具体类来对其执行一些测试。除了对BaseDataObject的oid、created和modified属性进行价值导向测试外,我们已经建立了涵盖所有其他内容的模式:
-
对好值和坏值列表进行迭代,这些值对于被测试的成员来说是有意义的:
- (尚不适用)标准可选文本行值
- (尚未适用)标准要求的文本行值
- 布尔值(和数值等效值)
- (尚不适用)非负数值
-
验证属性方法关联——到目前为止,在所有情况下都使用 getter 方法,在需要使用 setter 和 deleter 方法的地方使用 setter 和 deleter 方法
-
验证 getter 方法是否检索其底层存储属性值
-
验证删除程序方法是否按预期重置其基础存储属性值
-
验证 setter 方法是否按预期执行类型检查和值检查
-
验证初始化方法(
__init__是否按预期调用了所有 deleter 和 setter 方法
这三个相同的属性(oid、created和modified)除了没有已经定义的已建立的测试模式外,还具有另一个共同的特征:如果请求了该属性,并且还没有该属性,那么这三个属性都将创建一个值(即,底层存储属性的值是None。该行为需要一些额外的测试,超出了 getter 读取测试方法开始的存储属性的正常确认(使用test_get_created来说明):
def test_get_created(self):
# Tests the _get_created method of the BaseDataObject class
test_object = BaseDataObjectDerived()
expected = 'expected value'
test_object._created = expected
actual = test_object.created
self.assertEquals(actual, expected,
'_get_created was expected to return "%s" (%s), but '
'returned "%s" (%s) instead' %
(
expected, type(expected).__name__,
actual, type(actual).__name__
)
)到目前为止,测试方法是 getter 方法测试的典型,它设置了一个任意值(因为测试的是 getter 是否检索该值,仅此而已),并验证结果是否是设置的结果。不过,接下来,我们强制存储属性的值为 None,并验证 getter 方法的结果是否为本例中适当类型 adatetime的对象:
test_object._created = None
self.assertEqual(type(test_object._get_created()), datetime,
'BaseDataObject._get_created should return a '
'datetime value if it\'s retrieved from an instance '
'with an underlying None value'
)属性设置器方法(本例中为_set_created的测试方法)必须考虑属性合法的所有不同类型变化—datetime、int、float和_set_created的str值相同–并在调用正在测试的方法并检查结果之前,根据输入类型相应地设置期望值:
def test_set_created(self):
# Tests the _set_created method of the BaseDataObject class
test_object = BaseDataObjectDerived()
# - Test all "good" values
for created in GoodDateTimes:
if type(created) == datetime:
expected = created
elif type(created) in (int, float):
expected = datetime.fromtimestamp(created)
elif type(created) == str:
expected = datetime.strptime(
created, BaseDataObject._data_time_string
)
test_object._set_created(created)
actual = test_object.created
self.assertEqual(
actual, expected,
'Setting created to "%s" (%s) should return '
'"%s" (%s) through the property, but "%s" (%s) '
'was returned instead' %
(
created, type(created).__name__,
expected, type(expected).__name__,
actual, type(actual).__name__,
)
)
# - Test all "bad" values
for created in BadDateTimes:
try:
test_object._set_created(created)
self.fail(
'BaseDataObject objects should not accept "%s" '
'(%s) as created values, but it was allowed to '
'be set' %
(created, type(created).__name__)
)
except (TypeError, ValueError):
pass
except Exception as error:
self.fail(
'BaseDataObject objects should raise TypeError '
'or ValueError if passed a created value of '
'"%s" (%s), but %s was raised instead:\n'
' %s' %
(
created, type(created).__name__,
error.__class__.__name__, error
)
)deleter 方法测试在结构上与我们之前实现的测试过程相同,不过:
def test_del_created(self):
# Tests the _del_created method of the BaseDataObject class
test_object = BaseDataObjectDerived()
test_object._created = 'unexpected value'
test_object._del_created()
self.assertEquals(
test_object._created, None,
'BaseDataObject._del_created should leave None in the '
'underlying storage attribute, but "%s" (%s) was '
'found instead' %
(
test_object._created,
type(test_object._created).__name__
)
)完全相同的结构,将created更改为modified,测试modified属性的基本方法。一个非常相似的结构,将名称(created更改为oid)和预期类型(datetime更改为UUID),作为测试oid属性的属性方法的起点。
测试_get_oid,则如下所示:
def test_get_oid(self):
# Tests the _get_oid method of the BaseDataObject class
test_object = BaseDataObjectDerived()
expected = 'expected value'
test_object._oid = expected
actual = test_object.oid
self.assertEquals(actual, expected,
'_get_oid was expected to return "%s" (%s), but '
'returned "%s" (%s) instead' %
(
expected, type(expected).__name__,
actual, type(actual).__name__
)
)
test_object._oid = None
self.assertEqual(type(test_object.oid), UUID,
'BaseDataObject._get_oid should return a UUID value '
'if it\'s retrieved from an instance with an '
'underlying None value'
)测试_set_oid如下所示(注意,类型更改还必须考虑不同的预期类型和值):
def test_set_oid(self):
# Tests the _set_oid method of the BaseDataObject class
test_object = BaseDataObjectDerived()
# - Test all "good" values
for oid in GoodOIDs:
if type(oid) == UUID:
expected = oid
elif type(oid) == str:
expected = UUID(oid)
test_object._set_oid(oid)
actual = test_object.oid
self.assertEqual(
actual, expected,
'Setting oid to "%s" (%s) should return '
'"%s" (%s) through the property, but "%s" '
'(%s) was returned instead.' %
(
oid, type(oid).__name__,
expected, type(expected).__name__,
actual, type(actual).__name__,
)
)
# - Test all "bad" values
for oid in BadOIDs:
try:
test_object._set_oid(oid)
self.fail(
'BaseDatObject objects should not accept '
'"%s" (%s) as a valid oid, but it was '
'allowed to be set' %
(oid, type(oid).__name__)
)
except (TypeError, ValueError):
pass
except Exception as error:
self.fail(
'BaseDataObject objects should raise TypeError '
'or ValueError if passed a value of "%s" (%s) '
'as an oid, but %s was raised instead:\n'
' %s' %
(
oid, type(oid).__name__,
error.__class__.__name__, error
)
)所有的数据对象测试都已完成(目前),现在正是将包头文件(hms_core/__init__.py)中的类定义移动到模块文件中的好时机,只针对它们:business_objects.py。虽然这纯粹是一个名称空间组织问题(因为没有任何类本身被改变,只是它们在包中的位置被改变),但从长远来看,这是一个非常有意义的问题。移动完成后,将对驻留在包中的类进行逻辑分组:
业务对象定义以及与这些类型直接关联的项都将位于hms_core.business_objects命名空间中,并且可以从该命名空间导入,例如:
from hms_core.business_objects import BaseArtisan如果需要,hms_core.business_objects的所有成员可以通过以下方式进口:
import hms_core.business_objects类似地,与仍在开发中的数据对象结构相关的功能都将存在于hms_core.data_objects名称空间中:
from hms_core.data_objects import BaseDataObject或者,模块的所有成员都可以通过以下方式导入:
import hms_core.data_objects在基本数据对象结构准备就绪并经过测试后,是时候开始实现一些具体的、数据持久化的业务对象了,从 Artisan 应用程序中的业务对象开始。
BaseDataObject的实现为我们之前确定的所有常见数据访问需求(所有 CRUD 操作)提供了机制:
- 它允许派生数据对象在实例化后创建和更新其状态数据
- 它提供了一种单一的机制,允许从数据存储中读取一个或多个数据对象,并且作为一个额外的功能,它允许基于标准(而不仅仅是相关数据对象的
oid)进行某种程度的对象检索 - 它提供了删除对象数据的单一机制
这些方法的实际实现是数据对象本身的责任,它将与每个对象类型使用的存储机制直接相关。
Artisan 应用程序的数据存储(将数据读写到用户机器上的本地文件)在许多方面是两个要实现的数据存储选项中比较简单的一个,因此我们将从这一点开始。



