odoo框架给我们单元测试提供了BaseCase.assertRaises之类的方法去assert异常类型,一直在使用从未探究过其实现。今天突然来了兴趣,仔细看看收获不少。

我们在断言一种异常时,经常这样用:

1
2
with self.assertRaises(ValidateError):
    do_something()

这里如果do_something方法执行抛出异常,而异常类型为ValidateError,就会被识别到,从而断言为True。这里是如何捕获到这个异常,进而实现断言的呢? 这里就要提到“with"这个关键字和python提供的上下文管理器

关于with的解释网络上很多,简单来说就是实现了____enter____和____exit____方法的对象就叫做“上下文管理器(context manager)”,可以用with,举个数据库连接的例子:

1
2
3
4
5
6
7
8
9
class Connnection(object):
    def __enter__(self):
        return self

    def __exit__(self, err_type, value, traceback):
        self.close()

with Connnection() as con:
    do_something()

可以看到有了with就不用繁琐地写try/finaly了。执行的顺序是先执行Connection()得到一个实例,然后执行Connection类的____enter____返回这个实例,由于存在as,这个实例会被赋值给con,然后执行do_something(),完成后最后执行Connection类的____exit____方法。同时可以看到____exit____方法有三个参数,这是因为当do_something执行抛出异常的时候,如果do_something后面还有代码,就不会执行了,而会直接进入到____exit____,其中err_type是异常的类型,value是异常具体对象,traceback是异常时的堆栈,对于接下来的处理,如果____exit____返回False则异常会继续往上抛,如果返回True则不在继续抛。这就是一个catch机制嘛,难怪可以替代try啦。

在看看BaseCase.assertRaises的实现:

1
2
3
4
5
6
@contextmanager
def _assertRaises(self, exception):
    """ Context manager that clears the environment upon failure. """
    with super(BaseCase, self).assertRaises(exception) as cm:
        with self.env.clear_upon_failure():
            yield cm

这里出现了一个contextmanager装饰器。讲python的上下文管理器,contextlib.contextmanager是一定会被提到的。它作用到一个generator上使得实现上下文管理器的过程更简化了。被contextlib.contextmanager装饰的generator,yield之前的部分作为____enter____方法,yield之后的部分作为____exit____方法。执行到yield时就执行with包围的do_something的内容。但需要注意的是这个generator只能输出一个值,如果是多个值就不能用contextlib.contextmanager。那么之前的数据库连接的例子就可以改为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Connection(object):
    pass

def get_con():
    con = Connection()
    yield con
    con.close()

with get_con() as con:
    do_something()

这样就不必每个想要用with的对象都要实现一遍____enter____和____exit____。接下来看看让我产生兴趣的一个地方,关于with的嵌套。我把异常断言用到的主要代码列出来:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
with self.assertRaises(ValidateError): # 1.由于assertRaises是一个generator,这里获取到这个generator对象后就会进入这个对象的__enter__方法,就是执行assertRaises方法yield之前的部分
    do_something() # 4.
...

@contextmanager
def _assertRaises(self, exception):
    """ Context manager that clears the environment upon failure. """
    with super(BaseCase, self).assertRaises(exception) as cm:   #  2.执行到这里的时候可以看到super(BaseCase, self).assertRaises(exception)返回的是一个_AssertRaisesContext对象,它实现了__enter__和__exit__方法
        with self.env.clear_upon_failure(): # 3.执行到这里的时候由于clear_upon_failure方法是一个generator,所以会执行yield之前的部分(啥也没有),然后到8, 然后完成3这一层with的"__enter__",执行下一句5直接是yield,那么对于_assertRaises来说,其yield方法已经执行完了,那么回到最开始的with,开始执行do_something方法4
            yield cm    # 5.
...

class _AssertRaisesContext(object):
    """A context manager used to implement TestCase.assertRaises* methods."""

    def __init__(self, expected, test_case, expected_regexp=None):
        self.expected = expected
        self.failureException = test_case.failureException
        self.expected_regexp = expected_regexp

    def __enter__(self):    # 6.
        return self

    def __exit__(self, exc_type, exc_value, tb): # 7.这里是异常类型断言的核心逻辑,通过在__exit__里判断异常类型与expected是否一致来决定是返回True还是False来决定异常断言的结果
        if exc_type is None:
            try:
                exc_name = self.expected.__name__
            except AttributeError:
                exc_name = str(self.expected)
            raise self.failureException(
                "{0} not raised".format(exc_name))
        if not issubclass(exc_type, self.expected):
            # let unexpected exceptions pass through
            return False
        self.exception = exc_value # store for later retrieval
        if self.expected_regexp is None:
            return True

        expected_regexp = self.expected_regexp
        if not expected_regexp.search(str(exc_value)):
            raise self.failureException('"%s" does not match "%s"' %
                     (expected_regexp.pattern, str(exc_value)))
        return True
...

@contextmanager
def clear_upon_failure(self):
    """ Context manager that clears the environments (caches and fields to
        recompute) upon exception.
    """
    try:
        yield   #  8.
    except Exception:
        self.clear()    #9.
        raise   # 10.

这里要注意,如果是实现了____enter____和____exit____的对象,当do_something执行异常后会进入____exit____,但对于用contextlib.contextmanager装饰的generator来说,do_something异常之后并不会再执行yield之后的内容,所以在4执行异常的时候,异常被3这一层with机制捕获,接着进入clear_upon_failure的异常捕获逻辑,执行9,做一个资源清理,然后10将异常往外抛被2这一层的with机制捕获进入到7这个____exit____逻辑,在这里通过对比异常类型,如果这不是期待的类型,则抛出“不匹配”的异常,如果是期待的类型则返回True,异常不继续往上抛。

这就是这个异常断言的实现机制,可以这个过程中with的嵌套分两种:

一种是对于1来说,assertRaises方法得到的是一个generator,所以它的____enter____是整个assertRaises方法yield之前的内容,可以理解为一直执行到assertRaises方法yield了才算完,这里在某些情况下可以不仅仅只执行到5。

而且如果4执行没有异常,这时候就要执行assertRaises方法yield后面的内容,而5这里yield后没有东西了,但并不意味着assertRaises方法的yield后面的内容执行完了,还需要执行完3这一层with的”exit“以及2这一层的”exit",而执行3这一层的"exit“时,由于clear_upon_failure是一个generator,会要执行clear_upon_failure方法yield后面的内容,也是什么都没有,于是3这一层的with执行完了;2这层with也是一样会执行到7这里的____exit____,然后没有异常所以返回False,至此整个assertRaises方法执行完了,也是1这个with的”exit“过程执行完了。整个过程就完了。

另一种其实在第一种的过程中包含了。在执行1的”enter“过程的时候,进入到assertRaises,现执行外层with的”enter“过程,然后执行外层with的"do_something"过程时执行到内层with的”enter“过程。执行完内层的"do_something"过程后先执行内层with的”exit“过程。