Django formset unit test

Viewed 12725

I can't run a unit test with formset.

I try to do a test:

class NewClientTestCase(TestCase):
    
    def setUp(self):
        self.c = Client()

    def test_0_create_individual_with_same_adress(self):
        
        post_data =  {
            'ctype': User.CONTACT_INDIVIDUAL,
            'username': 'dupond.f',        
            'email': 'new@gmail.com', 
            'password': 'pwd', 
            'password2': 'pwd', 
            'civility': User.CIVILITY_MISTER, 
            'first_name': 'François', 
            'last_name': 'DUPOND', 
            'phone': '+33 1 34 12 52 30', 
            'gsm': '+33 6 34 12 52 30', 
            'fax': '+33 1 34 12 52 30', 
            'form-0-address1': '33 avenue Gambetta', 
            'form-0-address2': 'apt 50', 
            'form-0-zip_code': '75020', 
            'form-0-city': 'Paris', 
            'form-0-country': 'FRA', 
            'same_for_billing': True,            
        }
        
        response = self.c.post(reverse('client:full_account'), post_data, follow=True)   

        self.assertRedirects(response, '%s?created=1' % reverse('client:dashboard'))

and I have this error:

ValidationError: [u'ManagementForm data is missing or has been tampered with']

My view :

def full_account(request, url_redirect=''):    
    from forms import NewUserFullForm,  AddressForm,  BaseArticleFormSet
    
    fields_required = []
    fields_notrequired = []
    
    AddressFormSet = formset_factory(AddressForm, extra=2,  formset=BaseArticleFormSet)
    
    if request.method == 'POST':        
        form = NewUserFullForm(request.POST)        
        objforms = AddressFormSet(request.POST)            
       
        if objforms.is_valid() and form.is_valid():            
            user = form.save()            
            address = objforms.forms[0].save()

            
            if url_redirect=='':
                url_redirect = '%s?created=1' % reverse('client:dashboard')
                logon(request, form.instance)            
            return HttpResponseRedirect(url_redirect)
    else:
        form = NewUserFullForm()
        objforms = AddressFormSet()   
    
    return direct_to_template(request, 'clients/full_account.html', {
        'form':form,
        'formset': objforms, 
        'tld_fr':False, 
    })

and my form file :

class BaseArticleFormSet(BaseFormSet):

    def clean(self):        
        
        msg_err = _('Ce champ est obligatoire.')
        non_errors = True
        
        if 'same_for_billing' in self.data and self.data['same_for_billing'] == 'on':
            same_for_billing = True
        else:            
            same_for_billing = False
        
        for i in [0, 1]:
            
            form = self.forms[i]           
            
            for field in form.fields:                                
                name_field = 'form-%d-%s' % (i, field )
                value_field = self.data[name_field].strip()                
                
                if i == 0 and self.forms[0].fields[field].required and value_field =='':                    
                    form.errors[field] = msg_err                    
                    non_errors = False
                    
                elif i == 1 and not same_for_billing and self.forms[1].fields[field].required and value_field =='':
                    form.errors[field] = msg_err                    
                    non_errors = False
        
        return non_errors

class AddressForm(forms.ModelForm):

    class Meta:
        model = Address

    address1 = forms.CharField()
    address2 = forms.CharField(required=False)
    zip_code = forms.CharField()
    city = forms.CharField()
    country = forms.ChoiceField(choices=CountryField.COUNTRIES,  initial='FRA')
7 Answers

In particular, I've found that the ManagmentForm validator is looking for the following items to be POSTed:

form_data = {
            'form-TOTAL_FORMS': 1, 
            'form-INITIAL_FORMS': 0 
}

You can add following test helper methods to your test class [Python 3 code]

def build_formset_form_data(self, form_number, **data):
    form = {}
    for key, value in data.items():
        form_key = f"form-{form_number}-{key}"
        form[form_key] = value
    return form

def build_formset_data(self, forms, **common_data):
    formset_dict = {
        "form-TOTAL_FORMS": f"{len(forms)}",
        "form-MAX_NUM_FORMS": "1000",
        "form-INITIAL_FORMS": "1"
    }
    formset_dict.update(common_data)
    for i, form_data in enumerate(forms):
        form_dict = self.build_formset_form_data(form_number=i, **form_data)
        formset_dict.update(form_dict)
    return formset_dict

And use them in test

def test_django_formset_post(self):
    forms = [{"key1": "value1", "key2": "value2"}, {"key100": "value100"}]
    payload = self.build_formset_data(forms=forms, global_param=100)
    print(payload)
    # self.client.post(url=url, data=payload)

You will get correct payload which makes Django ManagementForm happy

{
    "form-INITIAL_FORMS": "1",
    "form-TOTAL_FORMS": "2",
    "form-MAX_NUM_FORMS": "1000",
    "global_param": 100,
    "form-0-key1": "value1",
    "form-0-key2": "value2",
    "form-1-key100": "value100",
}

Profit

There are several very useful answers here, e.g. pymen's and Raffi's, that show how to construct properly formatted payload for a formset post using the test client.

However, all of them still require at least some hand-coding of prefixes, dealing with existing objects, etc., which is not ideal.

As an alternative, we could create the payload for a post() using the response obtained from a get() request:

def create_formset_post_data(response, new_form_data=None):
    if new_form_data is None:
        new_form_data = []
    csrf_token = response.context['csrf_token']
    formset = response.context['formset']
    prefix_template = formset.empty_form.prefix  # default is 'form-__prefix__'
    # extract initial formset data
    management_form_data = formset.management_form.initial
    form_data_list = formset.initial  # this is a list of dict objects
    # add new form data and update management form data
    form_data_list.extend(new_form_data)
    management_form_data['TOTAL_FORMS'] = len(form_data_list)
    # initialize the post data dict...
    post_data = dict(csrf_token=csrf_token)
    # add properly prefixed management form fields
    for key, value in management_form_data.items():
        prefix = prefix_template.replace('__prefix__', '')
        post_data[prefix + key] = value
    # add properly prefixed data form fields
    for index, form_data in enumerate(form_data_list):
        for key, value in form_data.items():
            prefix = prefix_template.replace('__prefix__', f'{index}-')
            post_data[prefix + key] = value
    return post_data

The output (post_data) will also include form fields for any existing objects.

Here's how you might use this in a Django TestCase:

def test_post_formset_data(self):
    url_path = '/my/post/url/'
    user = User.objects.create()
    self.client.force_login(user)
    # first GET the form content
    response = self.client.get(url_path)
    self.assertEqual(HTTPStatus.OK, response.status_code)
    # specify form data for test
    test_data = [
        dict(first_name='someone', email='someone@email.com', ...),
        ...
    ]
    # convert test_data to properly formatted dict
    post_data = create_formset_post_data(response, new_form_data=test_data)
    # now POST the data
    response = self.client.post(url_path, data=post_data, follow=True)
    # some assertions here
    ...

Some notes:

  • Instead of using the 'TOTAL_FORMS' string literal, we could import TOTAL_FORM_COUNT from django.forms.formsets, but that does not seem to be public (at least in Django 2.2).

  • Also note that the formset adds a 'DELETE' field to each form if can_delete is True. To test deletion of existing items, you can do something like this in your test:

      ...
      post_data = create_formset_post_data(response)
      post_data['form-0-DELETE'] = True
      # then POST, etc.
      ...
    
  • From the source, we can see that there is no need include MIN_NUM_FORM_COUNT and MAX_NUM_FORM_COUNT in our test data:

    MIN_NUM_FORM_COUNT and MAX_NUM_FORM_COUNT are output with the rest of the management form, but only for the convenience of client-side code. The POST value of them returned from the client is not checked.

Related