Repository: rossmacarthur/serde

Issue:
Store Model and Field chain in SerdeErrors
If there is a serialization, deserialization, or validation failure on some nested Model, it would be nice to traverse the Model and Field objects that the failure occurred in.

Discussion:
codecov-io: # [Codecov](https://codecov.io/gh/rossmacarthur/serde/pull/80?src=pr&el=h1) Report
> Merging [#80](https://codecov.io/gh/rossmacarthur/serde/pull/80?src=pr&el=desc) into [master](https://codecov.io/gh/rossmacarthur/serde/commit/404b9a9ef0788c7b63866293a3c877c3838a8dfe?src=pr&el=desc) will **not change** coverage.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/rossmacarthur/serde/pull/80/graphs/tree.svg?width=650&token=kZWHiIewTt&height=150&src=pr)](https://codecov.io/gh/rossmacarthur/serde/pull/80?src=pr&el=tree)

```diff
@@          Coverage Diff          @@
##           master    #80   +/-   ##
=====================================
  Coverage     100%   100%           
=====================================
  Files           6      6           
  Lines         600    640   +40     
=====================================
+ Hits          600    640   +40
```


| [Impacted Files](https://codecov.io/gh/rossmacarthur/serde/pull/80?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/serde/exceptions.py](https://codecov.io/gh/rossmacarthur/serde/pull/80/diff?src=pr&el=tree#diff-c3JjL3NlcmRlL2V4Y2VwdGlvbnMucHk=) | `100% <100%> (ø)` | :arrow_up: |
| [src/serde/fields.py](https://codecov.io/gh/rossmacarthur/serde/pull/80/diff?src=pr&el=tree#diff-c3JjL3NlcmRlL2ZpZWxkcy5weQ==) | `100% <100%> (ø)` | :arrow_up: |
| [src/serde/model.py](https://codecov.io/gh/rossmacarthur/serde/pull/80/diff?src=pr&el=tree#diff-c3JjL3NlcmRlL21vZGVsLnB5) | `100% <100%> (ø)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/rossmacarthur/serde/pull/80?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/rossmacarthur/serde/pull/80?src=pr&el=footer). Last update [404b9a9...02e1102](https://codecov.io/gh/rossmacarthur/serde/pull/80?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/src/serde/exceptions.py b/src/serde/exceptions.py
index 784671d..17c3075 100644
--- a/src/serde/exceptions.py
+++ b/src/serde/exceptions.py
@@ -2,9 +2,13 @@
 This module contains Exception classes that are used in Serde.
 """
 
+from collections import namedtuple
+
 
 __all__ = [
+    'ContextError',
     'DeserializationError',
+    'InstantiationError',
     'NormalizationError',
     'SerdeError',
     'SerializationError',
@@ -46,7 +50,7 @@ class BaseSerdeError(Exception):
 
     def __str__(self):
         """
-        Return a string representation of this BaseSerdeError..
+        Return a string representation of this BaseSerdeError.
         """
         return self.message or self.__class__.__name__
 
@@ -63,71 +67,231 @@ class MissingDependency(BaseSerdeError):
     """
 
 
+class ContextError(BaseSerdeError):
+    """
+    Raised when Models or Fields are used in the wrong context.
+    """
+
+
 class SerdeError(BaseSerdeError):
     """
-    Raised when serializing, deserializing, or validating Models fails.
+    Raised when any Model stage fails.
+
+    ::
+
+        >>> try:
+        ...     class User(Model):
+        ...         age = fields.Int(validators=[validate.between(0, 100)])
+        ...
+        ...     User.from_dict({'age': -1})
+        ... except SerdeError as e:
+        ...     error = e
+        ...
+        >>> error.cause
+        <serde.exceptions.ValidationError: expected at least 0 but got -1>
+        >>> error.value
+        -1
+        >>> error.field.name
+        'age'
+        >>> error.model.__name__
+        'User'
     """
 
+    Context = namedtuple('Context', 'cause value field model')
+
     def __init__(self, message, cause=None, value=None, field=None, model=None):
         """
         Create a new SerdeError.
 
         Args:
             message (str): a message describing the error that occurred.
-            cause (Exception): the exception that caused this error.
-            value: the Field value context.
-            field (~serde.fields.Field): the Field context.
-            model (~serde.model.Model): the Model context.
+            cause (Exception): an exception for this context.
+            value: the value which caused this error.
+            field (serde.fields.Field): the Field where this error happened.
+            model (serde.model.Model): the Model where this error happened.
         """
         super(SerdeError, self).__init__(message)
-        self.cause = None
-        self.value = None
-        self.field = None
-        self.model = None
-        self.add_context(cause=cause, value=value, field=field, model=model)
+        self.contexts = []
+
+        if cause or value or field or model:
+            self.add_context(cause, value, field, model)
 
     def add_context(self, cause=None, value=None, field=None, model=None):
         """
-        Add cause/value/field/model context.
+        Add another context to this SerdeError.
+
+        Args:
+            cause (Exception): an exception for this context.
+            value: the value which caused this error.
+            field (~serde.fields.Field): the Field where this error happened.
+            model (~serde.model.Model): the Model where this error happened.
+        """
+        self.contexts.append(SerdeError.Context(cause, value, field, model))
+
+    def iter_contexts(self):
+        """
+        Iterate through the contexts in reverse order.
+        """
+        return reversed(self.contexts)
+
+    @classmethod
+    def from_exception(cls, exception, value=None, field=None, model=None):
+        """
+        Create a new SerdeError from another Exception.
+
+        Args:
+            exception (Exception): the Exception to convert from.
+            value: the value which caused this error.
+            field (~serde.fields.Field): the Field where this error happened.
+            model (~serde.model.Model): the Model where this error happened.
+
+        Returns:
+            SerdeError: an instance of SerdeError.
+        """
+        if isinstance(exception, SerdeError):
+            self = cls(exception.message)
+            exception.contexts, self.contexts = [], exception.contexts
+            self.add_context(exception, value, field, model)
+            return self
+        else:
+            return cls(str(exception) or repr(exception), exception, value, field, model)
+
+    def __getattr__(self, name):
+        """
+        Get an attribute of a SerdeError.
+        """
+        if name in ('cause', 'value', 'field', 'model'):
+            for context in self.contexts:
+                value = getattr(context, name)
+
+                if value is not None:
+                    return value
+
+            return None
+
+        return self.__getattribute__(name)
+
+    @staticmethod
+    def _pretty_context(context, seperator='\n', prefix='Due to => ', indent=4):
+        """
+        Pretty format the given Context.
 
         Args:
-            cause (Exception): the exception that caused this error.
-            value: the Field value context.
-            field (~serde.fields.Field): the Field context.
-            model (~serde.model.Model): the Model context.
+            seperator (str): the seperator for each context.
+            prefix (str): the prefix for each context. Example: 'Caused by: '.
+            indent (int): the number of spaces to indent each context line.
+
+        Returns:
+            str: the pretty formatted Context.
         """
-        if cause is not None:
-            self.cause = cause
+        lines = []
+
+        if context.value or context.field or context.model:
+            s = ''
+
+            if context.value is not None:
+                value = repr(context.value)
+
+                if len(value) > 30:
+                    value = value[:26] + '... '
+
+                s += 'value {} '.format(value)
 
-        if value is not None:
-            self.value = value
+            if context.field is not None:
+                s += 'for field {!r} of type {!r} '.format(
+                    context.field._name,
+                    context.field.__class__.__name__
+                )
 
-        if field is not None:
-            self.field = field
+            if context.model is not None:
+                s += 'on model {!r} '.format(context.model.__name__)
 
-        if model is not None:
-            self.model = model
+            lines.append(s.strip())
+
+        if context.cause is not None:
+            if isinstance(context.cause, SerdeError):
+                lines.append(context.cause.pretty())
+            else:
+                lines.append(repr(context.cause) or str(context.cause))
+
+        return seperator.join(' ' * indent + prefix + s for s in lines)
+
+    def pretty(self, seperator='\n', prefix='Due to => ', indent=4):
+        """
+        Return a pretty string representation of this SerdeError.
+
+        Args:
+            seperator (str): the seperator for each context.
+            prefix (str): the prefix for each context. Example: 'Caused by: '.
+            indent (int): the number of spaces to indent each context line.
+
+        Returns:
+            str: the pretty formatted SerdeError.
+        """
+        lines = [self.__class__.__name__]
+
+        if self.message:
+            lines[0] += ': ' + self.message
+
+        lines.extend([
+            self._pretty_context(
+                context,
+                seperator=seperator,
+                prefix=prefix,
+                indent=indent
+            )
+            for context in self.iter_contexts()
+        ])
+
+        return seperator.join(lines)
 
 
 class SerializationError(SerdeError):
     """
     Raised when field serialization fails.
+
+    This would be experienced when calling a serialization method like
+    `Model.to_dict() <serde.model.Model.to_dict()>`.
     """
 
 
 class DeserializationError(SerdeError):
     """
     Raised when field deserialization fails.
+
+    This would be experienced when calling a deserialization method like
+    `Model.from_dict() <serde.model.Model.from_dict()>`.
+    """
+
+
+class InstantiationError(SerdeError):
+    """
+    Raised when field instantiation fails.
+
+    This would be experienced when instantiating a Model using
+    `Model.__init__() <serde.model.Model.__init__()>`.
     """
 
 
 class NormalizationError(SerdeError):
     """
     Raised when field normalization fails.
+
+    This would be experienced when normalizing a Model using
+    `Model.normalize_all() <serde.model.Model.normalize_all()>`. However, since
+    this method is automatically called when deserializing or instantiating a
+    Model you would not typically catch this exception because it would be
+    converted to an `InstantiationError` or `DeserializationError`.
     """
 
 
 class ValidationError(SerdeError):
     """
     Raised when field validation fails.
+
+    This would be experienced when validating a Model using
+    `Model.validate_all() <serde.model.Model.normalize_all()>`. However, since
+    this method is automatically called when deserializing or instantiating a
+    Model you would not typically catch this exception because it would be
+    converted to an `InstantiationError` or `DeserializationError`.
     """
diff --git a/src/serde/fields.py b/src/serde/fields.py
index a163e9f..3dc81ca 100644
--- a/src/serde/fields.py
+++ b/src/serde/fields.py
@@ -31,7 +31,7 @@ default operations.
     >>> Person.from_dict({'name': 'Beyonce', 'fave_number': 4})
     Traceback (most recent call last):
     ...
-    serde.exceptions.ValidationError: value is not odd!
+    serde.exceptions.DeserializationError: value is not odd!
 
 The `create()` method can be used to generate a new Field class from arbitrary
 functions without having to manually subclass a Field. For example if we wanted
@@ -55,7 +55,7 @@ import uuid
 import isodate
 
 from serde import validate
-from serde.exceptions import SerdeError, SkipSerialization, ValidationError
+from serde.exceptions import ContextError, SerdeError, SkipSerialization, ValidationError
 from serde.utils import try_import_all, zip_equal
 
 
@@ -204,11 +204,11 @@ class Field(object):
         Set a named attribute on a Field.
 
         Raises:
-            `~serde.exceptions.SerdeError`: when the _name attribute is set
+            `~serde.exceptions.ContextError: when the _name attribute is set
                 after it has already been set.
         """
         if name == '_name' and hasattr(self, '_name'):
-            raise SerdeError('Field instance used multiple times')
+            raise ContextError('Field instance used multiple times')
 
         super(Field, self).__setattr__(name, value)
 
@@ -753,12 +753,12 @@ class Dict(Instance):
         >>> Example({'pi': '3.1415927'})
         Traceback (most recent call last):
             ...
-        serde.exceptions.ValidationError: expected 'float' but got 'str'
+        serde.exceptions.InstantiationError: expected 'float' but got 'str'
 
         >>> Example.from_dict({'constants': {100: 3.1415927}})
         Traceback (most recent call last):
             ...
-        serde.exceptions.ValidationError: expected 'str' but got 'int'
+        serde.exceptions.DeserializationError: expected 'str' but got 'int'
     """
 
     def __init__(self, key=None, value=None, **kwargs):
@@ -865,12 +865,12 @@ class List(Instance):
         >>> User(emails=1234)
         Traceback (most recent call last):
             ...
-        serde.exceptions.ValidationError: expected 'list' but got 'int'
+        serde.exceptions.InstantiationError: 'int' object is not iterable
 
         >>> User.from_dict({'emails': [1234]})
         Traceback (most recent call last):
             ...
-        serde.exceptions.ValidationError: expected 'str' but got 'int'
+        serde.exceptions.DeserializationError: expected 'str' but got 'int'
     """
 
     def __init__(self, element=None, **kwargs):
@@ -978,12 +978,12 @@ class Tuple(Instance):
         >>> Person('Beyonce', birthday=(4, 'September'))
         Traceback (most recent call last):
             ...
-        serde.exceptions.ValidationError: iterables have different lengths
+        serde.exceptions.InstantiationError: iterables have different lengths
 
         >>> Person.from_dict({'name': 'Beyonce', 'birthday': (4, 9, 1994)})
         Traceback (most recent call last):
             ...
-        serde.exceptions.ValidationError: expected 'str' but got 'int'
+        serde.exceptions.DeserializationError: expected 'str' but got 'int'
     """
 
     def __init__(self, *elements, **kwargs):
@@ -1124,7 +1124,7 @@ class Choice(Field):
         >>> Car('yellow')
         Traceback (most recent call last):
         ...
-        serde.exceptions.ValidationError: 'yellow' is not a valid choice
+        serde.exceptions.InstantiationError: 'yellow' is not a valid choice
     """
 
     def __init__(self, choices, **kwargs):
@@ -1284,7 +1284,7 @@ class Uuid(Instance):
         >>> User('not a uuid')
         Traceback (most recent call last):
         ...
-        serde.exceptions.ValidationError: expected 'UUID' but got 'str'
+        serde.exceptions.InstantiationError: expected 'UUID' but got 'str'
     """
 
     def __init__(self, **kwargs):
diff --git a/src/serde/model.py b/src/serde/model.py
index 14bcfaa..5d0a036 100644
--- a/src/serde/model.py
+++ b/src/serde/model.py
@@ -77,7 +77,7 @@ from functools import wraps
 from six import with_metaclass
 
 from serde.exceptions import (
-    DeserializationError, MissingDependency, NormalizationError,
+    DeserializationError, InstantiationError, MissingDependency, NormalizationError,
     SerdeError, SerializationError, SkipSerialization, ValidationError
 )
 from serde.fields import Field
@@ -149,7 +149,7 @@ def map_errors(error, model=None, field=None, value=None):
         e.add_context(value=value, field=field, model=model)
         raise
     except Exception as e:
-        raise error(str(e) or repr(e), cause=e, value=value, field=field, model=model)
+        raise error.from_exception(e, value=value, field=field, model=model)
 
 
 class Fields(OrderedDict):
@@ -157,6 +157,12 @@ class Fields(OrderedDict):
     An OrderedDict that allows value access with dot notation.
     """
 
+    def names(self):
+        """
+        Provides a view on Field names.
+        """
+        return [field.name for field in self.values()]
+
     def __getattr__(self, name):
         """
         Return values in the dictionary using attribute access with keys.
@@ -253,15 +259,16 @@ class Model(with_metaclass(ModelType, object)):
             setattr(self, name, kwargs.pop(name, None))
 
         if kwargs:
-            raise SerdeError(
+            raise InstantiationError(
                 'invalid keyword argument{} {}'.format(
                     '' if len(kwargs.keys()) == 1 else 's',
                     ', '.join('{!r}'.format(k) for k in kwargs.keys())
                 )
             )
 
-        self.normalize_all()
-        self.validate_all()
+        with map_errors(InstantiationError):
+            self.normalize_all()
+            self.validate_all()
 
     def __eq__(self, other):
         """
@@ -329,20 +336,12 @@ class Model(with_metaclass(ModelType, object)):
         This is called by the Model constructor and on deserialization, so this
         is only needed if you modify attributes directly and want to renormalize
         the Model.
-
-        Normalization errors are ignored by default.
         """
         for name, field in self._fields.items():
-            try:
-                setattr(self, name, self._normalize_field(field, getattr(self, name)))
-            except NormalizationError:
-                pass
+            setattr(self, name, self._normalize_field(field, getattr(self, name)))
 
-        try:
-            with map_errors(NormalizationError, model=self.__class__):
-                self.normalize()
-        except NormalizationError:
-            pass
+        with map_errors(NormalizationError, model=self.__class__):
+            self.normalize()
 
     def validate_all(self):
         """
@@ -396,14 +395,14 @@ class Model(with_metaclass(ModelType, object)):
             ...     dogs_name = fields.Optional(fields.Str)
             ...
             ...     def validate(self):
-            ...         msg = 'No one is a cat *and* a dog person!'
+            ...         msg = 'No one has both!'
             ...         assert not (self.cats_name and self.dogs_name), msg
             ...
 
             >>> owner = Owner(cats_name='Luna', dogs_name='Max')
             Traceback (most recent call last):
                 ...
-            serde.exceptions.ValidationError: No one is a cat *and* a dog person!
+            serde.exceptions.InstantiationError: No one has both!
         """
         pass
 
@@ -412,8 +411,6 @@ class Model(with_metaclass(ModelType, object)):
         """
         Convert a dictionary to an instance of this Model.
 
-        The given dictionary will be consumed by this operation.
-
         Args:
             d (dict): a serialized version of this Model.
             strict (bool): if set to False then no exception will be raised when
@@ -432,21 +429,25 @@ class Model(with_metaclass(ModelType, object)):
             value = None
 
             if field.name in d:
-                value = self._deserialize_field(field, d.pop(field.name))
+                value = self._deserialize_field(field, d.get(field.name))
 
             setattr(self, name, value)
 
-        if strict and d:
-            raise DeserializationError(
-                'unknown dictionary key{} {}'.format(
-                    '' if len(d.keys()) == 1 else 's',
-                    ', '.join('{!r}'.format(k) for k in d.keys())
-                ),
-                model=cls
-            )
+        if strict:
+            allowed = cls._fields.names()
+            unknown = [key for key in d.keys() if key not in allowed]
+
+            if unknown:
+                raise DeserializationError(
+                    'unknown dictionary key{} {}'.format(
+                        '' if len(unknown) == 1 else 's',
+                        ', '.join('{!r}'.format(k) for k in unknown)
+                    )
+                )
 
-        self.normalize_all()
-        self.validate_all()
+        with map_errors(DeserializationError):
+            self.normalize_all()
+            self.validate_all()
 
         return self
 


Test patch:
diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py
index 4da1c2b..d38ad5d 100644
--- a/tests/test_exceptions.py
+++ b/tests/test_exceptions.py
@@ -1,16 +1,175 @@
-from serde.exceptions import BaseSerdeError, SerdeError
+from pytest import raises
+
+from serde import Model, fields, validate
+from serde.exceptions import BaseSerdeError, DeserializationError, SerdeError, ValidationError
 
 
 def test_base_error():
-    e = BaseSerdeError('something failed')
+    error = BaseSerdeError('something failed')
+
+    assert repr(error) == '<serde.exceptions.BaseSerdeError: something failed>'
+    assert str(error) == 'something failed'
+
+
+class TestSerdeError:
+
+    def test___init___basic(self):
+        # You should be able to construct SerdeError without any context.
+        error = SerdeError('something failed')
+        assert error.message == 'something failed'
+        assert error.contexts == []
+
+    def test___init___context(self):
+        # You should be able to construct a SerdeError with some context.
+        cause = ValueError()
+        error = SerdeError('something failed', cause=cause, value=5)
+        assert error.message == 'something failed'
+        assert error.contexts[0].cause == cause
+        assert error.contexts[0].value == 5
+        assert error.cause == cause
+        assert error.value == 5
+
+    def test_add_context(self):
+        # You should be able to add more contexts.
+        error = SerdeError('something failed')
+        error.add_context(cause=ValueError())
+        error.add_context(cause=TypeError())
+        assert isinstance(error.contexts[0].cause, ValueError)
+        assert isinstance(error.contexts[1].cause, TypeError)
+
+    def test_iter_contexts(self):
+        # You should be able to iterate through the contexts.
+        error = SerdeError('something failed')
+        error.contexts = ['a', 'b', 'c']
+        assert list(error.iter_contexts()) == error.contexts[::-1]
+
+    def test_from_exception_generic(self):
+        # You should be able to convert any exception into a SerdeError.
+        exception = ValueError('something failed')
+        error = SerdeError.from_exception(exception)
+        assert error.message == 'something failed'
+        assert error.contexts[0].cause == exception
+
+    def test_from_exception_serde_error(self):
+        # You be able to convert any SerdeError error into another SerdeError.
+        exception = ValidationError('it is invalid')
+        error = DeserializationError.from_exception(exception)
+        assert error.message == 'it is invalid'
+        assert error.contexts[0].cause == exception
+        assert error.contexts[0].cause.contexts == []
+
+    def test___getattr__valid(self):
+        # Test that cause, value, field, and model attributes are accessible.
+        error = SerdeError('something failed', value=1)
+        error.add_context(field=2)
+        error.add_context(model=3)
+
+        assert error.cause is None
+        assert error.value == 1
+        assert error.field == 2
+        assert error.model == 3
+
+        with raises(AttributeError):
+            assert error.not_a_real_attribute_i_hope
+
+    def test__pretty_context_basic(self):
+        # Test that a Context object is correctly pretty formatted.
+        class Example(Model):
+            a = fields.Int()
+
+        context = SerdeError.Context(
+            cause=ValidationError('something failed'),
+            value='test',
+            field=Example._fields.a,
+            model=Example
+        )
+
+        assert SerdeError._pretty_context(context, seperator='; ', prefix=':: ', indent=0) == (
+            ":: value 'test' for field 'a' of type 'Int' on model 'Example'; "
+            ':: ValidationError: something failed'
+        )
+
+    def test__pretty_context_generic_exception(self):
+        # Test that cause can also work with a generic exception.
+        context = SerdeError.Context(cause=ValueError(), value=None, field=None, model=None)
+        assert SerdeError._pretty_context(context) == '    Due to => ValueError()'
+
+    def test__pretty_context_long_value(self):
+        # Test that a Context object is correctly pretty formatted.
+        context = SerdeError.Context(cause=None, value='a' * 40, field=None, model=None)
+        expected = "    Due to => value 'aaaaaaaaaaaaaaaaaaaaaaaaa..."
+        assert SerdeError._pretty_context(context) == expected
+
+    def test_pretty_use_case_nested(self):
+        # Test a better use case for the pretty formatted error.
+
+        class SubExample(Model):
+            a = fields.Str(validators=[validate.length_between(0, 5)])
+
+        class Example(Model):
+            sub = fields.Nested(SubExample)
+
+        try:
+            Example.from_dict({
+                'sub': {
+                    'a': 'testing'
+                }
+            })
+            assert False
+        except DeserializationError as e:
+            assert e.pretty() == (
+                'DeserializationError: expected at most 5 but got 7\n'
+                "    Due to => value {'a': 'testing'} for field 'sub' of type 'Nested' on model 'Example'\n"  # noqa: E501
+                '    Due to => ValidationError: expected at most 5 but got 7\n'
+                "    Due to => value 'testing' for field 'a' of type 'Str' on model 'SubExample'"
+            )
+
+    def test_pretty_use_case_missing_keys(self):
+        # Test another use case where dictionary keys are missing.
+
+        class Example(Model):
+            a = fields.Int()
+
+        try:
+            Example.from_dict({})
+            assert False
+        except DeserializationError as e:
+            assert e.pretty() == (
+                'DeserializationError: None is not a valid Field value\n'
+                '    Due to => ValidationError: None is not a valid Field value\n'
+                "    Due to => for field 'a' of type 'Int' on model 'Example'"
+            )
+
+    def test_pretty_use_case_extra_keys(self):
+        # Test another use case where there are extra dictionary keys.
+
+        class SubExample(Model):
+            pass
+
+        class Example(Model):
+            sub = fields.Nested(SubExample)
 
-    assert repr(e) == '<serde.exceptions.BaseSerdeError: something failed>'
-    assert str(e) == 'something failed'
+        try:
+            Example.from_dict({
+                'sub': {
+                    'a': 5
+                }
+            })
+            assert False
+        except DeserializationError as e:
+            assert e.pretty() == (
+                "DeserializationError: unknown dictionary key 'a'\n"
+                "    Due to => value {'a': 5} for field 'sub' of type 'Nested' on model 'Example'"
+            )
 
+    def test_pretty_use_case_extra_keys_flat(self):
+        # Test another use case where there are extra dictionary keys.
 
-def test_error_context():
-    e = SerdeError('something failed')
-    cause = ValueError()
+        class Example(Model):
+            a = fields.Int()
 
-    e.add_context(cause=cause)
-    assert e.cause == cause
+        try:
+            Example.from_dict({'a': 5, 'b': 'test'})
+            assert False
+        except DeserializationError as e:
+            assert e.pretty() == "DeserializationError: unknown dictionary key 'b'"
diff --git a/tests/test_fields.py b/tests/test_fields.py
index 9d143b4..12f8224 100644
--- a/tests/test_fields.py
+++ b/tests/test_fields.py
@@ -5,7 +5,10 @@ from collections import OrderedDict
 from pytest import raises
 
 from serde import Model, validate
-from serde.exceptions import DeserializationError, SerdeError, SkipSerialization, ValidationError
+from serde.exceptions import (
+    ContextError, DeserializationError, InstantiationError,
+    SerdeError, SkipSerialization, ValidationError
+)
 from serde.fields import (
     Bool, Bytes, Choice, Complex, Date, DateTime, Dict, Field, Float, Instance, Int,
     List, Nested, Optional, Str, Time, Tuple, Uuid, _resolve_to_field_instance, create
@@ -86,7 +89,7 @@ class TestField:
     def test__setattr__(self):
         # The same Field instance should not be able to be used twice.
         field = Field()
-        with raises(SerdeError):
+        with raises(ContextError):
             class Example(Model):
                 a = field
                 b = field
@@ -213,7 +216,7 @@ def test_create_validator():
 
     assert Example('notderp').a == 'notderp'
 
-    with raises(ValidationError):
+    with raises(InstantiationError):
         Example('derp')
 
 
diff --git a/tests/test_model.py b/tests/test_model.py
index 2408439..d3a630f 100644
--- a/tests/test_model.py
+++ b/tests/test_model.py
@@ -6,7 +6,8 @@ from pytest import raises
 
 from serde import Model, fields, validate
 from serde.exceptions import (
-    DeserializationError, MissingDependency, SerdeError, SerializationError, ValidationError
+    DeserializationError, InstantiationError, MissingDependency,
+    NormalizationError, SerdeError, SerializationError, ValidationError
 )
 from tests import py2_only, py3_only
 
@@ -201,13 +202,13 @@ class TestModel:
 
         Example(a=5, b=None)
 
-        with raises(ValidationError):
+        with raises(InstantiationError):
             Example(a=None)
 
-        with raises(ValidationError):
+        with raises(InstantiationError):
             Example(a='test')
 
-        with raises(ValidationError):
+        with raises(InstantiationError):
             Example(b=5)
 
     def test___init___validators(self):
@@ -218,7 +219,7 @@ class TestModel:
 
         assert Example(a=101).a == 101
 
-        with raises(ValidationError):
+        with raises(InstantiationError):
             Example(a=5)
 
     def test___init___nested(self):
@@ -299,7 +300,7 @@ class TestModel:
         assert example.a == 'tset'
 
     def test_normalize_all_bad_normalizers(self):
-        # normalize_all() should catch all normalization exceptions.
+        # normalize_all() should raise normalization exceptions.
 
         class Example(Model):
             a = fields.Str()
@@ -309,21 +310,25 @@ class TestModel:
 
         example = Example(a='test')
         example._fields.a.normalizers = [raises_exception]
-        example.normalize_all()
-        assert example.a == 'test'
+
+        with raises(NormalizationError):
+            example.normalize_all()
 
     def test_normalize_all_bad_normalize(self):
-        # normalize_all() should catch all normalization exceptions.
+        # normalize_all() should raise a NormalizationError that will be mapped
+        # to an InstantiationError.
 
         class Example(Model):
             a = fields.Str()
 
-            def normalize(self):
-                raise ValueError
+        def normalize(self):
+            raise ValueError
 
         example = Example(a='test')
-        example.normalize_all()
-        assert example.a == 'test'
+        setattr(Example, 'normalize', normalize)
+
+        with raises(NormalizationError):
+            example.normalize_all()
 
     def test_validate_all(self):
         # validate_all() should revalidate the Model so that if we have changed
@@ -347,7 +352,7 @@ class TestModel:
             def validate(self):
                 assert self.a != 0
 
-        with raises(ValidationError):
+        with raises(InstantiationError):
             Example(a=0)
 
         example = Example(a=5)
@@ -363,7 +368,7 @@ class TestModel:
 
         try:
             Example(a='not an integer')
-        except ValidationError as e:
+        except InstantiationError as e:
             assert e.model is Example
             assert e.field is Example._fields.a
             assert e.value == 'not an integer'
@@ -376,6 +381,19 @@ class TestModel:
 
         assert Example.from_dict({}) == Example()
 
+    def test_from_dict_consumed(self):
+        # Check that a dictionary is left untouched during deserialization.
+
+        class SubExample(Model):
+            a = fields.Int()
+
+        class Example(Model):
+            sub = fields.Nested(SubExample)
+
+        d = {'sub': {'a': 5}}
+        Example.from_dict(d)
+        assert d == {'sub': {'a': 5}}
+
     def test_from_dict_required(self):
         # Check that required Fields have to be present when deserializing.
 
@@ -384,7 +402,7 @@ class TestModel:
 
         assert Example.from_dict({'a': 5}) == Example(a=5)
 
-        with raises(ValidationError):
+        with raises(DeserializationError):
             Example.from_dict({})
 
     def test_from_dict_optional(self):