Rohit Jain
Rohit Jain

Reputation: 213351

UnitTest ModelForm having ModelChoiceField with Mock Data

I've been trying to write unit test for my ModelForm, that has a ModelChoiceField. I'm creating the Form instance with mock data.

Here's my model:

# models.py
class Menu(models.Model):
    dish = models.ForeignKey(Dish, default=None)
    price = models.DecimalField(max_digits=7, decimal_places=2)

# forms.py
class MenuForm(forms.ModelForm):
    class Meta:
        model = Menu
        fields = ('dish', 'price',)

    def clean(self):
        cleaned_data = super(MenuForm, self).clean()
        price = cleaned_data.get('price', None)
        dish = cleaned_data.get('dish', None)

        # Some validation below
        if price < 70:
            self.add_error('price', 'Min price threshold')
            return cleaned_data

Here's my test case:

class MenuFormTest(TestCase):
    def test_price_threshold(self):
        mock_dish = mock.Mock(spec=Dish)
        form_data = {
            'dish': mock_dish,
            'price': 80,
        }
        form = forms.MenuForm(data=form_data)
        self.assertTrue(form.is_valid())

This fails with the following error:

<ul class="errorlist"><li>dish<ul class="errorlist"><li>Select a valid choice. That choice is not one of the available choices.</li></ul></li></ul>

How to make that avoid throw that error. form.is_valid() should have been True there. Is there a way I can patch the ModelChoiceField's queryset? I tried to patch form's dish field clean() method like below:

form = forms.MenuForm(data=form_data)
dish_clean_patcher = mock.patch.object(form.fields['dish'], 'clean')
dish_clean_patch = dish_clean_patcher.start()
dish_clean_patch.return_value = mock_dish

self.assertTrue(form.is_valid())

Then it looks like, it fails while saving form data to model instance in _post_clean() method. Here's the Traceback:

Traceback (most recent call last):
  File "/home/vagrant/venv/local/lib/python2.7/site-packages/mock/mock.py", line 1305, in patched
    return func(*args, **keywargs)
  File "/vagrant/myapp/tests/test_forms.py", line 51, in test_price_threshold
    self.assertFalse(form.is_valid())
  File "/home/vagrant/venv/local/lib/python2.7/site-packages/django/forms/forms.py", line 185, in is_valid
    return self.is_bound and not self.errors
  File "/home/vagrant/venv/local/lib/python2.7/site-packages/django/forms/forms.py", line 177, in errors
    self.full_clean()
  File "/home/vagrant/venv/local/lib/python2.7/site-packages/django/forms/forms.py", line 396, in full_clean
    self._post_clean()
  File "/home/vagrant/venv/local/lib/python2.7/site-packages/django/forms/models.py", line 427, in _post_clean
    self.instance = construct_instance(self, self.instance, opts.fields, construct_instance_exclude)
  File "/home/vagrant/venv/local/lib/python2.7/site-packages/django/forms/models.py", line 62, in construct_instance
    f.save_form_data(instance, cleaned_data[f.name])
  File "/home/vagrant/venv/local/lib/python2.7/site-packages/django/db/models/fields/__init__.py", line 874, in save_form_data
    setattr(instance, self.name, data)
  File "/home/vagrant/venv/local/lib/python2.7/site-packages/django/db/models/fields/related.py", line 632, in __set__
    instance._state.db = router.db_for_write(instance.__class__, instance=value)
  File "/home/vagrant/venv/local/lib/python2.7/site-packages/django/db/utils.py", line 300, in _route_db
    if instance is not None and instance._state.db:
  File "/home/vagrant/venv/local/lib/python2.7/site-packages/mock/mock.py", line 716, in __getattr__
    raise AttributeError("Mock object has no attribute %r" % name)
AttributeError: Mock object has no attribute '_state'

How do I avoid that part? I don't want it to look into instance._state.db at all.

Am I testing the form correctly? Or should I instead of calling form.is_valid(), just call form.clean() method, by patching the super(MenuForm, self).clean() method completely, and check form.errors?

Upvotes: 4

Views: 1574

Answers (2)

Pierre Criulanscy
Pierre Criulanscy

Reputation: 8686

If think your test is not "unit" enought. You seem to want to test the price treshold right ? Maybe you could do something like that :

# forms.py
class MenuForm(forms.ModelForm):
    class Meta:
        model = Menu
        fields = ('dish', 'price',)

    def clean(self):
        cleaned_data = super(MenuForm, self).clean()
        price = cleaned_data.get('price', None)
        dish = cleaned_data.get('dish', None)

        # Some validation below
        if not self._is_price_valid(price):
            self.add_error('price', 'Min price threshold')
            return cleaned_data

    def _is_price_valid(self, price):
        return price >= 70

And your test :

class MenuFormTest(TestCase):
    def test_price_threshold(self):
        form = forms.MenuForm()
        self.assertTrue(form._is_price_valid(80))

I agree that for this example it's a little "overkill" to just add a method ta returns a simple comparison but if you just want to test the price threshold without bothering with form validation process internal to Django it's not bad to isolate it

Upvotes: 0

quaspas
quaspas

Reputation: 1351

I would say calling form.is_valid() is a good way to test a form. I am not sure about mocking the model though.

Internally the form is calling get_limit_choices_to on your dish field (Which Django is currently creating for you).

You would need to mock the dish field's .queryset or get_limit_choices_to here, (or somewhere else in the call stack that makes the values here meaningless) in some way to achieve what you want.

Alternatively it would be much simpler to create a Dish inside your test and let Django's internals keep doing what they are doing.

class MenuFormTest(TestCase):
    def test_price_threshold(self):
        dish = Dish.objects.create(
            # my values here
        )
        form_data = {
            'dish': dish.id,
            'price': 80,
        }
        form = MenuForm(data=form_data)
        self.assertTrue(form.is_valid())

If you are really set on not using Django's test database, one strategy could be to mock the MenuForm.clean and MenuForm._post_clean:

class MenuFormTest(TestCase):
    def test_price_threshold(self):
        mock_dish = mock.Mock(spec=Dish)
        form_data = {
            'dish': 1,
            'price': 80,
        }
        form = MenuForm(data=form_data)
        form.fields['dish'].clean = lambda _: mock_dish
        form._post_clean = lambda : None
        self.assertTrue(form.is_valid())

You will need to ask yourself what your goal is with this test if you are going to do this.

Upvotes: 7

Related Questions