Repository: grappa-py/grappa

Issue:
Expect and Should have unexpected behavior with parens and .be.true or .be.false
Allow me to explain.

The following assertion fails as expected:
```
        True | should.be.false
```

Neither of the following fail:
```
        expect(True).to.be.false
        should(True).be.false
```

I think this is a bug, and I'd love to contribute if pointers are given

Discussion:

Patch:
diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml
new file mode 100644
index 0000000..e918bc5
--- /dev/null
+++ b/.github/workflows/python-package.yml
@@ -0,0 +1,67 @@
+# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
+# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
+
+name: Python CI
+
+on:
+  push:
+    branches: [ master ]
+  pull_request:
+    branches: [ master ]
+
+jobs:
+  build:
+    strategy:
+      matrix:
+        os: [ubuntu-latest, macos-latest, windows-latest]
+        python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9, pypy3]
+        experimental: [false]
+        include:
+          - os: ubuntu-latest
+            python-version: pypy2
+            experimental: false
+          - os: ubuntu-latest
+            python-version: 3.10-dev
+            experimental: true
+
+    runs-on: ${{ matrix.os }}
+    continue-on-error: ${{ matrix.experimental }}
+    name: Python ${{ matrix.python-version }} on ${{ matrix.os }}
+
+    steps:
+    - uses: actions/checkout@v2
+
+    - name: Set up Python ${{ matrix.python-version }}
+      uses: actions/setup-python@v2
+      if: "!endsWith(matrix.python-version, '-dev')"
+      with:
+        python-version: ${{ matrix.python-version }}
+
+    - name: Set up Python ${{ matrix.python-version }}
+      uses: deadsnakes/action@v2.0.0
+      if: endsWith(matrix.python-version, '-dev')
+      with:
+        python-version: ${{ matrix.python-version }}
+      env:
+        ACTIONS_ALLOW_UNSECURE_COMMANDS: true
+  
+    - name: Install dependencies
+      run: |
+        pip install -r requirements-dev.txt
+        pip install -r requirements.txt
+
+    - name: Lint with flake8
+      run: |
+        # stop the build if there are Python syntax errors or undefined names
+        flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
+        # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
+        flake8 . --count --max-complexity=10 --statistics
+
+    - name: Test with pytest
+      run: |
+        pytest -s -v --tb=native --capture=sys --cov grappa --cov-report term-missing tests
+
+    - name: Coverage
+      run: |
+        coverage run --source grappa -m py.test
+        coverage report
diff --git a/.travis.yml b/.travis.yml
index 70695eb..82e9d83 100755
--- a/.travis.yml
+++ b/.travis.yml
@@ -6,13 +6,16 @@ python:
   - "3.6"
   - "3.7"
   - "3.8"
-  - pypy
+  - "3.9"
   - pypy3
   - nightly
 
-matrix:
+jobs:
   allow_failures:
     - python: nightly
+  include:
+    - python: pypy
+      env: CRYPTOGRAPHY_ALLOW_OPENSSL_102=true
 
 sudo: false
 
diff --git a/grappa/decorators.py b/grappa/decorators.py
index d6de252..71d8f48 100644
--- a/grappa/decorators.py
+++ b/grappa/decorators.py
@@ -2,6 +2,7 @@
 import inspect
 import functools
 import six
+from .api import TestProxy
 from .engine import Engine
 from .operator import Operator
 
@@ -73,3 +74,76 @@ def register(operator):
     """
     Engine.register(operator)
     return operator
+
+
+def mock_implementation_validator(func):
+    """
+    Validate that a mock conform to the implementation required to run
+    have_been and have_been_with operators.
+
+    Otherwise returns a Grappa tuple with missing implementation reasons.
+
+    Arguments:
+        operator (Operator): a been_called or been_called_with operator.
+        subject: a mock whose implementation is to be validated.
+        *args: variadic arguments.
+        **kw: variadic keyword arguments.
+
+    Returns:
+        (function|tuple)
+    """
+    @functools.wraps(func)
+    def wrapper(operator, subject, *args, **kwargs):
+        expect = TestProxy('expect')
+
+        propery_reason_template = 'a property named "{}" is expected'
+
+        def validate_props(reasons, prop):
+            try:
+                expect(subject).to.have.property(prop)
+            except AssertionError:
+                reasons.append(propery_reason_template.format(prop))
+            return reasons
+
+        method_reason_template = 'a method named "{}" is expected'
+
+        def validate_methods(reasons, method):
+            try:
+                expect(subject).to.implement.methods(method)
+            except AssertionError:
+                reasons.append(method_reason_template.format(method))
+            return reasons
+
+        expected_props = ('called', 'call_count')
+        reasons = functools.reduce(validate_props, expected_props, [])
+
+        expected_methods = ('assert_called_with', 'assert_called_once_with')
+        reasons = functools.reduce(validate_methods, expected_methods, reasons)
+
+        if reasons:
+            operator.information = (
+                Operator.Dsl.Help(
+                    Operator.Dsl.Description(
+                        'Required implementation is based on unittest.mock.Mock class.', # noqa E501
+                        '',
+                        'Properties required',
+                        '    {}'.format(', '.join(expected_props)),
+                        'Methods required',
+                        '    {}'.format(', '.join(expected_methods)),
+                        '',
+                    ),
+                    Operator.Dsl.Reference(
+                        'https://docs.python.org/3/library/unittest.mock.html#the-mock-class', # noqa E501
+                    ),
+                    Operator.Dsl.Reference(
+                        'https://pypi.org/project/pytest-mock/',
+                    ),
+                ),
+            )
+
+            reasons.insert(0, 'mock implementation is incomplete')
+            return False, reasons
+
+        return func(operator, subject, *args, **kwargs)
+
+    return wrapper
diff --git a/grappa/operators/__init__.py b/grappa/operators/__init__.py
index ab9efdc..bcf5230 100644
--- a/grappa/operators/__init__.py
+++ b/grappa/operators/__init__.py
@@ -27,6 +27,12 @@ operators = (
     ('implements',  'ImplementsOperator'),
     ('raises',      'RaisesOperator'),
 
+    ('been_called',      'BeenCalledOperator',
+                         'BeenCalledTimesOperator',
+                         'BeenCalledOnceOperator'),
+    ('been_called_with', 'BeenCalledWithOperator',
+                         'BeenCalledOnceWithOperator'),
+
     ('bool',        'TrueOperator', 'FalseOperator'),
     ('start_end',   'StartWithOperator', 'EndWithOperator'),
 
diff --git a/grappa/operators/attributes.py b/grappa/operators/attributes.py
index 7748bfd..a131f7e 100644
--- a/grappa/operators/attributes.py
+++ b/grappa/operators/attributes.py
@@ -13,7 +13,7 @@ def be(ctx):
     Semantic attributes providing chainable declarative DSL
     for assertions.
     """
-    ctx.negate = False
+    pass
 
 
 @attribute(operators=(
diff --git a/grappa/operators/been_called.py b/grappa/operators/been_called.py
new file mode 100644
index 0000000..96f0823
--- /dev/null
+++ b/grappa/operators/been_called.py
@@ -0,0 +1,152 @@
+# -*- coding: utf-8 -*-
+from ..decorators import mock_implementation_validator
+from ..operator import Operator
+
+
+class BeenCalledOperator(Operator):
+    """
+    Asserts if a given mock subject have been called at least once.
+
+    Warning::
+
+        Piping style assertions is not yet supported.
+
+    Example::
+
+        # Should style
+        should(mock).have.been_called
+
+        # Should style - negation form
+        should(mock).have_not.been_called
+
+        # Expect style
+        expect(mock).to.have.been_called
+
+        # Expect style - negation form
+        expect(mock).to.have_not.been_called
+        expect(mock).to_not.have.been_called
+    """
+
+    # Is the operator a keyword
+    kind = Operator.Type.ACCESSOR
+
+    # Disable diff report
+    show_diff = False
+
+    # Operator keywords
+    operators = ('been_called',)
+
+    # Error message templates
+    expected_message = Operator.Dsl.Message(
+        'a mock that has been called at least once',
+        'a mock that has not been called',
+    )
+
+    # Subject message template
+    subject_message = Operator.Dsl.Message(
+        'a mock that has not been called',
+        'a mock that has been called at least once',
+    )
+
+    @mock_implementation_validator
+    def match(self, subject):
+        return subject.called
+
+
+class BeenCalledOnceOperator(Operator):
+    """
+    Asserts if a given mock subject have been called once.
+
+    Warning::
+
+        Piping style assertions is not yet supported.
+
+    Example::
+
+        # Should style
+        should(mock).have.been_called_once
+
+        # Should style - negation form
+        should(mock).have_not.been_called_once
+
+        # Expect style
+        expect(mock).to.have.been_called_once
+
+        # Expect style - negation form
+        expect(mock).to.have_not.been_called_once
+        expect(mock).to_not.have.been_called_once
+    """
+
+    # Is the operator a keyword
+    kind = Operator.Type.ACCESSOR
+
+    # Disable diff report
+    show_diff = False
+
+    # Operator keywords
+    operators = ('been_called_once',)
+
+    # Error message templates
+    expected_message = Operator.Dsl.Message(
+        'a mock that has been called once',
+        'a mock that has not been called once',
+    )
+
+    # Subject message template
+    subject_message = Operator.Dsl.Message(
+        'a mock that has been called {call_count} time(s)',
+        'a mock that has been called once',
+    )
+
+    @mock_implementation_validator
+    def match(self, subject):
+        return subject.call_count == 1
+
+
+class BeenCalledTimesOperator(Operator):
+    """
+    Asserts if a given mock subject have been called n times.
+
+    Warning::
+
+        Piping style assertions is not yet supported.
+
+    Example::
+
+        # Should style
+        should(mock).have.been_called_times(3)
+
+        # Should style - negation form
+        should(mock).have_not.been_called_times(3)
+
+        # Expect style
+        expect(mock).to.have.been_called_times(0)
+
+        # Expect style - negation form
+        expect(mock).to.have_not.been_called_times(1)
+        expect(mock).to_not.have.been_called_times(3)
+    """
+
+    # Is the operator a keyword
+    kind = Operator.Type.MATCHER
+
+    # Disable diff report
+    show_diff = True
+
+    # Operator keywords
+    operators = ('been_called_times',)
+
+    # Error message templates
+    expected_message = Operator.Dsl.Message(
+        'a mock that has been called {value} times',
+        'a mock that has not been called {value} times',
+    )
+
+    subject_message = Operator.Dsl.Message(
+        'a mock that has been called {call_count} times',
+        'a mock that has not been called {call_count} times',
+    )
+
+    @mock_implementation_validator
+    def match(self, subject, expected):
+        return subject.call_count == expected
diff --git a/grappa/operators/been_called_with.py b/grappa/operators/been_called_with.py
new file mode 100644
index 0000000..6de0903
--- /dev/null
+++ b/grappa/operators/been_called_with.py
@@ -0,0 +1,113 @@
+# -*- coding: utf-8 -*-
+from ..decorators import mock_implementation_validator
+from ..operator import Operator
+
+
+class BeenCalledWithOperator(Operator):
+    """
+    Asserts if a given mock subject have been called at least once
+    with specified arguments.
+
+    Warning::
+
+        Piping style assertions is not yet supported.
+
+    Example::
+
+        # Should style
+        should(mock).have.been_called_with('foo')
+
+        # Should style - negation form
+        should(mock).have_not.been_called_with('foo', 10)
+
+        # Expect style
+        expect(mock).to.have.been_called_with('foo')
+
+        # Expect style - negation form
+        expect(mock).to.have_not.been_called_with('foo', True, 150)
+        expect(mock).to_not.have.been_called_with('foo')
+    """
+
+    # Is the operator a keyword
+    kind = Operator.Type.MATCHER
+
+    # Disable diff report
+    show_diff = False
+
+    # Operator keywords
+    operators = ('been_called_with',)
+
+    # Error message templates
+    expected_message = Operator.Dsl.Message(
+        'a mock that has been called at least once with arguments',
+        'a mock that has not been called with arguments',
+    )
+
+    # Subject message template
+    subject_message = Operator.Dsl.Message(
+        'a mock that has not been called with arguments',
+        'a mock that has been called at least once with arguments',
+    )
+
+    @mock_implementation_validator
+    def match(self, subject, *args, **kwargs):
+        try:
+            subject.assert_called_with(*args, **kwargs)
+            return True
+        except AssertionError as error:
+            return False, error.args[0].splitlines()
+
+
+class BeenCalledOnceWithOperator(Operator):
+    """
+    Asserts if a given mock subject have been called once
+    with specified arguments.
+
+    Warning::
+
+        Piping style assertions is not yet supported.
+
+    Example::
+
+        # Should style
+        should(mock).have.been_called_once_with('foo')
+
+        # Should style - negation form
+        should(mock).have_not.been_called_once_with('foo', 10)
+
+        # Expect style
+        expect(mock).to.have.been_called_once_with('foo')
+
+        # Expect style - negation form
+        expect(mock).to.have_not.been_called_once_with('foo', True, 150)
+        expect(mock).to_not.have.been_called_once_with('foo')
+    """
+
+    # Is the operator a keyword
+    kind = Operator.Type.MATCHER
+
+    # Disable diff report
+    show_diff = False
+
+    # Operator keywords
+    operators = ('been_called_once_with',)
+
+    # Error message templates
+    expected_message = Operator.Dsl.Message(
+        'a mock that has been called once with arguments',
+        'a mock that has not been called once with arguments',
+    )
+
+    # Subject message template
+    subject_message = Operator.Dsl.Message(
+        'a mock that has been called {call_count} time(s) with arguments',
+        'a mock that has been called once with arguments',
+    )
+
+    @mock_implementation_validator
+    def match(self, subject, *args, **kwargs):
+        try:
+            subject.assert_called_once_with(*args, **kwargs)
+            return True
+        except AssertionError as error:
+            return False, error.args[0].splitlines()
diff --git a/grappa/operators/contain.py b/grappa/operators/contain.py
index c320d1f..6da10c9 100644
--- a/grappa/operators/contain.py
+++ b/grappa/operators/contain.py
@@ -1,6 +1,8 @@
 # -*- coding: utf-8 -*-
+from array import array
 from six.moves import collections_abc
 import six
+
 from ..operator import Operator
 
 
@@ -59,26 +61,28 @@ class ContainOperator(Operator):
     NORMALIZE_TYPES = (
         collections_abc.Iterator,
         collections_abc.MappingView,
-        collections_abc.Set
+        collections_abc.Set,
+        array
     )
 
-    def match(self, subject, *expected):
+    LIST_TYPES = (tuple, list, set, array)
+
+    def match(self, subject, *values):
         if isinstance(subject, self.NORMALIZE_TYPES):
             subject = list(subject)
+        elif isinstance(subject, collections_abc.Mapping):
+            subject = list(subject.values())
 
-        if self._is_not_a_sequence(subject):
+        if not isinstance(subject, collections_abc.Sequence):
             return False, ['is not a valid sequence type']
 
-        return self._matches(subject, *expected)
-
-    def _is_not_a_sequence(self, value):
-        return not isinstance(value, collections_abc.Sequence)
-
-    def _matches(self, subject, *expected):
         reasons = []
 
-        for expected_item in expected:
-            matches_any, reason = self._matches_any(expected_item, subject)
+        if len(values) == 1 and isinstance(values[0], self.LIST_TYPES):
+            values = list(values[0])
+
+        for value in values:
+            matches_any, reason = self._matches_any(value, subject)
             reasons.append(reason)
 
             if not matches_any:
diff --git a/grappa/operators/implements.py b/grappa/operators/implements.py
index 464903d..8ce4d18 100644
--- a/grappa/operators/implements.py
+++ b/grappa/operators/implements.py
@@ -92,13 +92,17 @@ class ImplementsOperator(Operator):
             return False, ['methods to implement cannot be empty']
 
         def validate(reasons, method):
-            if not hasattr(cls, method):
-                reasons.append('object does not have property "{}"'.format(
-                    method
-                ))
-            elif not self.has_method(cls, method):
-                reasons.append('object does not implement method "{}"'.format(
-                    method
+            try:
+                if not hasattr(cls, method):
+                    template = 'object does not have property "{}"'
+                    reasons.append(template.format(method))
+                elif not self.has_method(cls, method):
+                    template = 'object does not implement method "{}"'
+                    reasons.append(template.format(method))
+            except Exception:
+                template = '"{}" is a "{}" but it must be a string'
+                reasons.append(template.format(
+                    method, method.__class__.__name__
                 ))
             return reasons
 
diff --git a/grappa/operators/keys.py b/grappa/operators/keys.py
index 45a2125..d1792f4 100644
--- a/grappa/operators/keys.py
+++ b/grappa/operators/keys.py
@@ -1,4 +1,5 @@
 # -*- coding: utf-8 -*-
+from array import array
 from six.moves import collections_abc
 
 from ..operator import Operator
@@ -13,18 +14,34 @@ class KeysOperator(Operator):
         # Should style
         {'foo': True} | should.have.key('foo')
         {'foo': True, 'bar': False} | should.have.keys('bar', 'foo')
+        {'foo': True, 'bar': False} | should.have.keys(('bar', 'foo'))
+        {'foo': True, 'bar': False} | should.have.keys(['bar', 'foo'])
+        {'foo': True, 'bar': False} | should.have.keys({'bar', 'foo'})
+        {1: True, 2: False} | should.have.keys(array('i', [1, 2]))
 
         # Should style - negation form
         {'bar': True} | should.not_have.key('foo')
         {'baz': True, 'bar': False} | should.not_have.keys('bar', 'foo')
+        {'baz': True, 'bar': False} | should.not_have.keys(('bar', 'foo'))
+        {'baz': True, 'bar': False} | should.not_have.keys(['bar', 'foo'])
+        {'baz': True, 'bar': False} | should.not_have.keys({'bar', 'foo'})
+        {10: True, 2: False} | should.not_have.keys(array('i', [1, 2]))
 
         # Expect style
         {'foo': True} | expect.to.have.key('foo')
         {'foo': True, 'bar': False} | expect.to.have.keys('bar', 'foo')
+        {'foo': True, 'bar': False} | expect.to.have.keys(('bar', 'foo'))
+        {'foo': True, 'bar': False} | expect.to.have.keys(['bar', 'foo'])
+        {'foo': True, 'bar': False} | expect.to.have.keys({'bar', 'foo'})
+        {1: True, 2: False} | expect.to.have.keys(array('i', [1, 2]))
 
         # Expect style - negation form
         {'bar': True} | expect.to_not.have.key('foo')
         {'baz': True, 'bar': False} | expect.to_not.have.keys('bar', 'foo')
+        {'baz': True, 'bar': False} | expect.to_not.have.keys(('bar', 'foo'))
+        {'baz': True, 'bar': False} | expect.to_not.have.keys(['bar', 'foo'])
+        {'baz': True, 'bar': False} | expect.to_not.have.keys({'bar', 'foo'})
+        {10: True, 2: False} | expect.to_not.have.keys(array('i', [1, 2]))
     """
 
     # Is the operator a keyword
@@ -47,57 +64,38 @@ class KeysOperator(Operator):
         'an object of type "{type}" with value "{value}"',
     )
 
+    LIST_TYPES = (tuple, list, set, array)
+
     def after_success(self, obj, *keys):
         if not self.ctx.negate:
             self.ctx.subject = [obj[x] for x in obj if x in keys]
 
         if len(keys) == 1 and len(self.ctx.subject):
-            self.ctx.subject = self.ctx.subject[0]
+            if isinstance(self.ctx.subject, list):
+                self.ctx.subject = self.ctx.subject[0]
+            else:
+                self.ctx.subject = list(self.ctx.subject.keys())[0]
 
-    def match(self, subject, *keys, **kw):
-        if self._not_a_dict(subject):
+    def match(self, subject, *keys):
+        if not isinstance(subject, collections_abc.Mapping):
             return False, ['subject is not a dict type']
-        return self._matches(subject, (keys, kw))
 
-    def _not_a_dict(self, value):
-        return not isinstance(value, collections_abc.Mapping)
+        reasons = []
 
-    def _matches(self, subject, expected):
-        args, kwargs = expected
+        if len(keys) == 1 and isinstance(keys[0], self.LIST_TYPES):
+            keys = list(keys[0])
 
-        reasons = []
-        for name in args:
-            has_key, reason = self._has_key(subject, name)
-            if not has_key:
-                return False, [reason]
+        for name in keys:
+            if name in subject:
+                has_key = True
+                reason = 'key {0!r} found'.format(name)
             else:
-                reasons.append(reason)
+                has_key = False
+                reason = 'key {0!r} not found'.format(name)
 
-        for name, value in kwargs.items():
-            has_key, reason = self._has_key(subject, name, value)
             if not has_key:
                 return False, [reason]
-            else:
-                reasons.append(reason)
 
-        return True, reasons
-
-    def _has_key(self, subject, name, *args):
-        if args:
-            expected_value = args[0]
-
-            try:
-                value = subject[name]
-            except KeyError:
-                return False, 'key {0!r} {1!r} not found'.format(
-                    name, expected_value)
-            else:
-                result, _ = expected_value._match(value)
-                reason_message = 'not found' if not result else 'found'
-                return result, 'key {0!r} {1!r} {2}'.format(
-                    name, expected_value, reason_message)
-
-        if name in subject:
-            return True, 'key {0!r} found'.format(name)
+            reasons.append(reason)
 
-        return False, 'key {0!r} not found'.format(name)
+        return True, reasons
diff --git a/grappa/reporter.py b/grappa/reporter.py
index 9627cbb..5137761 100644
--- a/grappa/reporter.py
+++ b/grappa/reporter.py
@@ -33,7 +33,7 @@ class ErrorReporter(object):
 
         # Flag error as grappa generated error
         err.__grappa__ = True
-        err.error = error
+        err.__cause__ = getattr(error, 'error', error)
         err.context = self.ctx
 
         return err
diff --git a/grappa/reporters/base.py b/grappa/reporters/base.py
index 40ea6d8..de0e1d4 100644
--- a/grappa/reporters/base.py
+++ b/grappa/reporters/base.py
@@ -24,7 +24,7 @@ class BaseReporter(object):
         lines = value.split(os.linesep)
         return '\n    '.join(lines)
 
-    def normalize(self, value, size=20, use_raw=True):
+    def normalize(self, value, size=50, use_raw=True):
         if value is None:
             return value
 
@@ -73,6 +73,9 @@ class BaseReporter(object):
         if '{length}' in tmpl:
             placeholders['length'] = self.safe_length(value)
 
+        if '{call_count}' in tmpl:
+            placeholders['call_count'] = getattr(value, 'call_count', 0)
+
         return tmpl.format(**placeholders)
 
     def run(self, error):
diff --git a/grappa/resolver.py b/grappa/resolver.py
index a2c7b68..351bb5b 100644
--- a/grappa/resolver.py
+++ b/grappa/resolver.py
@@ -24,14 +24,14 @@ class OperatorResolver(object):
     def run_accessor(self, operator):
         # Register assertion function
         def assertion(subject):
-            self.ctx.subject = subject
+            operator.ctx.subject = subject
             return operator.run(subject)
 
         # Add assertion function
-        self.engine.add_assertion(assertion)
+        self.test._engine.add_assertion(assertion)
 
         # Self-trigger tests if running as global
-        if self.ctx.chained:
+        if self.ctx.chained or self.ctx.subject is not empty:
             self.test._trigger()
 
         return self.test
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 80f1627..7d001c2 100755
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,6 +1,7 @@
 coveralls~=1.1
 flake8~=3.7.9
 pytest~=3.0.3
+pytest-mock~=2.0.0
 pytest-cov~=2.4.0
 pytest-flakes~=1.0.1
 Sphinx~=1.5.3


Test patch:
diff --git a/tests/operators/attributes_test.py b/tests/operators/attributes_test.py
index debf0fb..ce3a93e 100644
--- a/tests/operators/attributes_test.py
+++ b/tests/operators/attributes_test.py
@@ -22,3 +22,11 @@ def test_negation_attributes(operator):
     assert getattr(should, operator)._ctx.negate
     assert isinstance(getattr(expect, operator), GrappaTest)
     assert getattr(expect, operator)._ctx.negate
+
+
+def test_negation_called_before_non_negation_attributes():
+    expect(False).to_not.have.true
+    expect(False).not_to.be.true
+
+    False | should.to_not.have.true
+    False | should.not_to.be.true
diff --git a/tests/operators/been_called_test.py b/tests/operators/been_called_test.py
new file mode 100644
index 0000000..7d37067
--- /dev/null
+++ b/tests/operators/been_called_test.py
@@ -0,0 +1,200 @@
+import os
+import pytest
+
+
+def test_been_called(expect, mocker):
+    mock_called = mocker.patch('os.path.join')
+    os.path.join('home', 'log.txt')
+
+    expect(mock_called).to.have.been_called
+
+    with pytest.raises(AssertionError):
+        expect(mock_called).to.have_not.been_called
+
+    mock_not_called = mocker.patch('os.rmdir')
+
+    expect(mock_not_called).to.have_not.been_called
+
+    with pytest.raises(AssertionError):
+        expect(mock_not_called).to.have.been_called
+
+    mock_called_several_times = mocker.patch('os.rename')
+    os.rename('/home/log.txt', '/home/log_new.txt')
+    os.rename('/home/log.txt', '/home/log_new.txt')
+
+    expect(mock_called_several_times).to.have.been_called
+
+    with pytest.raises(AssertionError):
+        expect(mock_called_several_times).to.have_not.been_called
+
+
+def test_been_called_times(expect, mocker):
+    mock_called = mocker.patch('os.path.join')
+    os.path.join('home', 'log.txt')
+    os.path.join('home', 'log.txt')
+    os.path.join('home', 'log.txt')
+
+    expect(mock_called).to.have.been_called_times(3)
+
+    with pytest.raises(AssertionError):
+        expect(mock_called).to.have_not.been_called_times(3)
+
+    mock_not_called = mocker.patch('os.rmdir')
+
+    expect(mock_not_called).to.have.been_called_times(0)
+    expect(mock_not_called).to.have_not.been_called_times(3)
+
+    with pytest.raises(AssertionError):
+        expect(mock_not_called).to.have.been_called_times(3)
+
+
+def test_been_called_with(expect, mocker):
+    mock_called = mocker.patch('os.path.join')
+    os.path.join('home', 'log.txt')
+
+    expect(mock_called).to.have.been_called_with('home', 'log.txt')
+
+    with pytest.raises(AssertionError):
+        expect(mock_called).to.have_not.been_called_with('home', 'log.txt')
+
+    mock_not_called = mocker.patch('os.rmdir')
+
+    expect(mock_not_called).to.have_not.been_called_with('home', 'log.txt')
+
+    with pytest.raises(AssertionError):
+        expect(mock_not_called).to.have.been_called_with('home', 'log.txt')
+
+
+def test_been_called_once(expect, mocker):
+    mock_called = mocker.patch('os.path.join')
+    os.path.join('home', 'log.txt')
+
+    expect(mock_called).to.have.been_called_once
+
+    with pytest.raises(AssertionError):
+        expect(mock_called).to.have_not.been_called_once
+
+    mock_not_called = mocker.patch('os.rmdir')
+
+    expect(mock_not_called).to.have_not.been_called_once
+
+    with pytest.raises(AssertionError):
+        expect(mock_not_called).to.have.been_called_once
+
+    mock_called_several_times = mocker.patch('os.rename')
+    os.rename('/home/log.txt', '/home/log_new.txt')
+    os.rename('/home/log.txt', '/home/log_new.txt')
+
+    expect(mock_called_several_times).to.have_not.been_called_once
+
+    with pytest.raises(AssertionError):
+        expect(mock_called_several_times).to.have.been_called_once
+
+
+def test_been_called_once_with(expect, mocker):
+    mock_called = mocker.patch('os.path.join')
+    os.path.join('home', 'log.txt')
+
+    expect(mock_called).to.have.been_called_once_with('home', 'log.txt')
+
+    with pytest.raises(AssertionError):
+        expect(mock_called).to_not.been_called_once_with('home', 'log.txt')
+
+    mock_not_called = mocker.patch('os.rmdir')
+
+    expect(mock_not_called).to.have_not.been_called_once_with('/home/log.txt')
+
+    with pytest.raises(AssertionError):
+        expect(mock_not_called).to.have.been_called_once_with('/home/log.txt')
+
+    mock_called_several_times = mocker.patch('os.rename')
+    os.rename('/home/log.txt', '/home/log_new.txt')
+    os.rename('/home/log.txt', '/home/log_new.txt')
+
+    expect(mock_called_several_times).to.have_not.been_called_once
+
+    with pytest.raises(AssertionError):
+        expect(mock_called_several_times).to.have.been_called_once
+
+
+def test_been_called_with_a_spy(expect, mocker):
+    class Foo():
+        def bar(self, string, padding):
+            return string.zfill(padding)
+
+    foo = Foo()
+    spy = mocker.spy(foo, 'bar')
+
+    expect(foo.bar('foo', 5)).to.be('00foo')
+
+    expect(spy).to.have.been_called
+    expect(spy).to.have.been_called_once
+    expect(spy).to.have.been_called_times(1)
+    expect(spy).to.have.been_called_with('foo', 5)
+    expect(spy).to.have.been_called_once_with('foo', 5)
+
+    with pytest.raises(AssertionError):
+        expect(spy).to.have_not.been_called
+
+    with pytest.raises(AssertionError):
+        expect(spy).to.have_not.been_called_with('foo', 5)
+
+    with pytest.raises(AssertionError):
+        expect(spy).to.have_not.been_called_once
+
+    with pytest.raises(AssertionError):
+        expect(spy).to.have_not.been_called_times(1)
+
+    with pytest.raises(AssertionError):
+        expect(spy).to.have_not.been_called_once_with('foo', 5)
+
+
+def test_been_called_with_a_stub(expect, mocker):
+    def foo(bar):
+        bar('test')
+
+    stub = mocker.stub('bar_stub')
+    foo(stub)
+
+    expect(stub).to.have.been_called
+    expect(stub).to.have.been_called_once
+    expect(stub).to.have.been_called_times(1)
+    expect(stub).to.have.been_called_with('test')
+    expect(stub).to.have.been_called_once_with('test')
+
+    with pytest.raises(AssertionError):
+        expect(stub).to.have_not.been_called
+
+    with pytest.raises(AssertionError):
+        expect(stub).to.have_not.been_called_with('test')
+
+    with pytest.raises(AssertionError):
+        expect(stub).to.have_not.been_called_once
+
+    with pytest.raises(AssertionError):
+        expect(stub).to.have_not.been_called_times(1)
+
+    with pytest.raises(AssertionError):
+        expect(stub).to.have_not.been_called_once_with('test')
+
+
+def test_been_called_with_an_incompatible_object(expect, mocker):
+    def foo():
+        pass
+
+    foo()
+
+    with pytest.raises(AssertionError):
+        expect(foo).to.have.been_called
+
+    with pytest.raises(AssertionError):
+        expect(foo).to.have.been_called_once
+
+    with pytest.raises(AssertionError):
+        expect(foo).to.have.been_called_times(1)
+
+    with pytest.raises(AssertionError):
+        expect(foo).to.have.been_called_with('something')
+
+    with pytest.raises(AssertionError):
+        expect(foo).to.have.been_called_once_with('something')
diff --git a/tests/operators/contain_test.py b/tests/operators/contain_test.py
index 55d356d..bd070de 100644
--- a/tests/operators/contain_test.py
+++ b/tests/operators/contain_test.py
@@ -1,3 +1,4 @@
+from array import array
 import pytest
 
 
@@ -6,11 +7,117 @@ def test_should_contain(should):
     'hello world' | should.contain('w') | should.contain('o')
 
     [1, 2, 3] | should.contain(1) | should.contain(3)
+
     ('foo', 'bar', 123) | should.contain('bar') | should.contain(123)
+
+    {'foo', 'bar', 123} | should.contain('bar') | should.contain(123)
+
     [{'foo': 1}] | should.contain({'foo': 1})
 
+    array('i', [1, 2, 3]) | should.contain(1) | should.contain(2)
+
+    {'foo': 'bar', 'fuu': 2} | should.contain('bar') | should.contain(2)
+
     with pytest.raises(AssertionError):
         'hello world' | should.contain('planet')
 
+    with pytest.raises(AssertionError):
+        'hello world' | should.contain('t')
+
     with pytest.raises(AssertionError):
         [1, 2, 3] | should.contain(4)
+
+    with pytest.raises(AssertionError):
+        ('foo', 'bar', 123) | should.contain('baz')
+
+    with pytest.raises(AssertionError):
+        {'foo', 'bar', 123} | should.contain('baz')
+
+    with pytest.raises(AssertionError):
+        [{'foo': 1}] | should.contain({'foo': 2})
+
+    with pytest.raises(AssertionError):
+        array('i', [1, 2, 3]) | should.contain(4)
+
+    with pytest.raises(AssertionError):
+        {'foo': 'bar', 'fuu': 2} | should.contain('baz')
+
+
+def test_should_contain_any(should):
+    'hello world' | should.contain('world', 'hello')
+    'hello world' | should.contain(('world', 'hello'))
+    'hello world' | should.contain(['world', 'hello'])
+    'hello world' | should.contain({'world', 'hello'})
+
+    'hello world' | should.contain('w', 'o')
+    'hello world' | should.contain(('w', 'o'))
+    'hello world' | should.contain(['w', 'o'])
+    'hello world' | should.contain({'w', 'o'})
+
+    [1, 2, 3] | should.contain(1, 3)
+    [1, 2, 3] | should.contain((1, 3))
+    [1, 2, 3] | should.contain([1, 3])
+    [1, 2, 3] | should.contain({1, 3})
+
+    ('foo', 'bar', 123) | should.contain('bar', 123)
+    {'foo', 'bar', 123} | should.contain(('bar', 123))
+    {'foo', 'bar', 123} | should.contain({'bar', 123})
+    {'foo', 'bar', 123} | should.contain(['bar', 123])
+
+    [{'foo': 1}, {'bar': 2}] | should.contain({'foo': 1}, {'bar': 2})
+    [{'foo': 1}, {'bar': 2}] | should.contain(({'foo': 1}, {'bar': 2}))
+    [{'foo': 1}, {'bar': 2}] | should.contain([{'foo': 1}, {'bar': 2}])
+
+    array('i', [1, 2, 3]) | should.contain(1, 2)
+    array('i', [1, 2, 3]) | should.contain((1, 2))
+    array('i', [1, 2, 3]) | should.contain({1, 2})
+    array('i', [1, 2, 3]) | should.contain([1, 2])
+
+    {'foo': 'bar', 'fuu': 'bor'} | should.contain('bar', 'bor')
+    {'foo': 'bar', 'fuu': 'bor'} | should.contain(('bar', 'bor'))
+    {'foo': 'bar', 'fuu': 'bor'} | should.contain(['bar', 'bor'])
+    {'foo': 'bar', 'fuu': 'bor'} | should.contain({'bar', 'bor'})
+
+
+def test_should_not_contain_any(should):
+    'hello planet' | should._not.contain('world', 'hello')
+    'hello planet' | should._not.contain(('world', 'hello'))
+    'hello planet' | should._not.contain(['world', 'hello'])
+    'hello planet' | should._not.contain({'world', 'hello'})
+
+    'hello planet' | should._not.contain('w', 'o')
+    'hello planet' | should._not.contain(('w', 'o'))
+    'hello planet' | should._not.contain(['w', 'o'])
+    'hello planet' | should._not.contain({'w', 'o'})
+
+    [1, 2, 3] | should._not.contain(1, 4)
+    [1, 2, 3] | should._not.contain((1, 4))
+    [1, 2, 3] | should._not.contain([1, 4])
+    [1, 2, 3] | should._not.contain({1, 4})
+
+    ('foo', 'bar', 123) | should._not.contain('baz', 123)
+    {'foo', 'bar', 123} | should._not.contain(('baz', 123))
+    {'foo', 'bar', 123} | should._not.contain({'baz', 123})
+    {'foo', 'bar', 123} | should._not.contain(['baz', 123])
+
+    [{'foo': 1}, {'bar': 2}] | should._not.contain({'foo': 1}, {'baz': 2})
+    [{'foo': 1}, {'bar': 2}] | should._not.contain(({'foo': 1}, {'baz': 2}))
+    [{'foo': 1}, {'bar': 2}] | should._not.contain([{'foo': 1}, {'baz': 2}])
+
+    array('i', [1, 2, 3]) | should._not.contain(1, 4)
+    array('i', [1, 2, 3]) | should._not.contain((1, 4))
+    array('i', [1, 2, 3]) | should._not.contain({1, 4})
+    array('i', [1, 2, 3]) | should._not.contain([1, 4])
+
+    {'foo': 'bar', 'fuu': 'bor'} | should._not.contain('baz', 'bor')
+    {'foo': 'bar', 'fuu': 'bor'} | should._not.contain(('baz', 'bor'))
+    {'foo': 'bar', 'fuu': 'bor'} | should._not.contain(['baz', 'bor'])
+    {'foo': 'bar', 'fuu': 'bor'} | should._not.contain({'baz', 'bor'})
+
+
+def test_should_contain_failures(should):
+    with pytest.raises(AssertionError):
+        () | should.contain('bar')
+
+    with pytest.raises(AssertionError):
+        1 | should.contain('bar')
diff --git a/tests/operators/implements_test.py b/tests/operators/implements_test.py
index 04051b2..57cc104 100644
--- a/tests/operators/implements_test.py
+++ b/tests/operators/implements_test.py
@@ -5,10 +5,10 @@ def test_implements(should):
     class foo(object):
         foo = None
 
-        def bar():
+        def bar(self):
             pass
 
-        def baz():
+        def baz(self):
             pass
 
     foo | should.implements.methods('bar', 'baz')
@@ -16,3 +16,6 @@ def test_implements(should):
 
     with pytest.raises(AssertionError):
         foo | should.implement.methods('foo', 'faa')
+
+    with pytest.raises(AssertionError):
+        foo | should.implements.methods(2, False)
diff --git a/tests/operators/keys_test.py b/tests/operators/keys_test.py
index 8a51e4f..896048e 100644
--- a/tests/operators/keys_test.py
+++ b/tests/operators/keys_test.py
@@ -1,7 +1,8 @@
 import pytest
+from array import array
 
 
-def test_expect_keys(should):
+def test_expect_key(should):
     {'foo': 'bar'} | should.have.key('foo')
     {'foo': 'bar'} | should.have.key('foo') > should.be.equal.to('bar')
     'bar' | should.be.equal.to('bar')
@@ -27,3 +28,70 @@ def test_expect_keys(should):
 
     with pytest.raises(AssertionError):
         should({'foo': 'bar'}).have.key('foo').which.should.be.equal.to('pepe')
+
+
+def test_not_expect_key(should):
+    {'foo': 'bar'} | should.not_have.key('foobar')
+
+    {'foo': 'bar', 'fuu': 'bor'} | should.not_have.key('foobar')
+
+    with pytest.raises(AssertionError):
+        {'foo': 'bar', 'fuu': 'bor'} | should.not_have.key('foo')
+
+
+def test_expect_keys(should):
+    myDict = {'foo': 'bar', 'fuu': 'bor'}
+
+    myDict | should.have.keys('foo', 'fuu')
+
+    myDict | should.have.keys(('foo', 'fuu'))
+
+    myDict | should.have.keys(['foo', 'fuu'])
+
+    myDict | should.have.keys({'foo', 'fuu'})
+
+    {1: 'bar', 2: 'bor'} | should.have.keys(array('i', [1, 2]))
+
+    with pytest.raises(AssertionError):
+        myDict | should.not_have.keys('foo', 'fuu')
+
+    with pytest.raises(AssertionError):
+        myDict | should.not_have.keys(('foo', 'fuu'))
+
+    with pytest.raises(AssertionError):
+        myDict | should.not_have.keys(['foo', 'fuu'])
+
+    with pytest.raises(AssertionError):
+        myDict | should.not_have.keys({'foo', 'fuu'})
+
+    with pytest.raises(AssertionError):
+        {1: 'bar', 2: 'bor'} | should.not_have.keys(array('i', [1, 2]))
+
+
+def test_not_expect_keys(should):
+    myDict = {'foo': 'baz', 'fuu': 'boz'}
+
+    myDict | should.not_have.keys('foo', 'bar')
+
+    myDict | should.not_have.keys(('foo', 'bar'))
+
+    myDict | should.not_have.keys(['foo', 'bar'])
+
+    myDict | should.not_have.keys({'foo', 'bar'})
+
+    {1: 'baz', 2: 'boz'} | should.not_have.keys(array('i', [1, 5]))
+
+    with pytest.raises(AssertionError):
+        myDict | should.have.keys('foo', 'bar')
+
+    with pytest.raises(AssertionError):
+        myDict | should.have.keys(('foo', 'bar'))
+
+    with pytest.raises(AssertionError):
+        myDict | should.have.keys(['foo', 'bar'])
+
+    with pytest.raises(AssertionError):
+        myDict | should.have.keys({'foo', 'bar'})
+
+    with pytest.raises(AssertionError):
+        {1: 'baz', 2: 'boz'} | should.have.keys(array('i', [1, 5]))