4

I do not understand how to manage updates on forms and related unit tests and I would really appreciate some advises =)

I have a Company model, and related very simple CompanyForm:

class Company(models.Model):
    """
    Company informations
    - Detailed information for display purposes in the application
      but also used in documents built and sent by the application
    - Mail information to be able to send emails
    """
    company_name = models.CharField("nom", max_length=200)
    comp_slug = models.SlugField("slug")
    logo = models.ImageField(upload_to="img/", null=True, blank=True)
    use_groups = models.BooleanField("utilise les groupes", default=False)   # Company uses groups or not
    rules = [("MAJ", "Majorité"), ("PROP", "Proportionnelle")]   # Default management rule
    rule = models.CharField(
        "mode de scrutin", max_length=5, choices=rules, default="MAJ"
    )
    upd_rule = models.BooleanField("choisir la règle de répartition pour chaque événement", default=False)     # Event rule might change from one to another or always use default
    statut = models.CharField("forme juridique", max_length=50)
    siret = models.CharField("SIRET", max_length=50)
    street_num = models.IntegerField("N° de rue", null=True, blank=True)
    street_cplt = models.CharField("complément", max_length=50, null=True, blank=True)
    address1 = models.CharField("adresse", max_length=300)
    address2 = models.CharField(
        "complément d'adresse", max_length=300, null=True, blank=True
    )
    zip_code = models.IntegerField("code postal")
    city = models.CharField("ville", max_length=200)
    host = models.CharField("serveur mail", max_length=50, null=True, blank=True)
    port = models.IntegerField("port du serveur", null=True, blank=True)
    hname = models.EmailField("utilisateur", max_length=100, null=True, blank=True)
    fax = models.CharField("mot de passe", max_length=50, null=True, blank=True)
    use_tls = models.BooleanField("authentification requise", default=True, blank=True)

    class Meta:
        verbose_name = "Société"
        constraints = [
            models.UniqueConstraint(fields=["comp_slug"], name="unique_comp_slug")
        ]

    def __str__(self):
        return self.company_name

    @classmethod
    def get_company(cls, slug):
        """ Retreive company from its slug """
        return cls.objects.get(comp_slug=slug)


class CompanyForm(forms.ModelForm):
    company_name = forms.CharField(label="Société", disabled=True)

    class Meta:
        model = Company
        exclude = []

The view is very simple too:

@user_passes_test(lambda u: u.is_superuser or u.usercomp.is_admin)
def adm_options(request, comp_slug):
    '''
        Manage Company options
    '''
    company = Company.get_company(comp_slug)
    comp_form = CompanyForm(request.POST or None, instance=company)

    if request.method == "POST":
        if comp_form.is_valid():
            comp_form.save()

    return render(request, "polls/adm_options.html", locals())

This view works fine, I can update information (it's actually not used for creation, which is done thanks to the Django Admin panel).

Unfortunately, I'm not able to build unit tests that will ensure update works!
I tried 2 ways, but none of them worked. My first try was the following:

class TestOptions(TestCase):
    def setUp(self):
        self.company = create_dummy_company("Société de test")
        self.user_staff = create_dummy_user(self.company, "staff", admin=True)
        self.client.force_login(self.user_staff.user)

    def test_adm_options_update(self):
        # Load company options page
        url = reverse("polls:adm_options", args=[self.company.comp_slug])
        response = self.client.get(url)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "0123456789")
        self.assertEqual(self.company.siret, "0123456789")

        # Options update
        response = self.client.post(
            reverse("polls:adm_options", args=[self.company.comp_slug]),
            {"siret": "987654321"}
        )
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "987654321")
        self.assertNotContains(response, "0123456789")
        self.assertEqual(self.company.siret, "987654321")

In this case, everything is OK but the latest assertion. It looks that the update has not been saved, which is actually not the case. I tried to Read the database just before, with the key stored in the context, but it remains the same.

I was looking for other information when I found this topic, so I tried another way to test, even if the approach surprised me a bit (I do not see how the view is actually tested).
Here is my second try (setUp() remains the same):

    def test_adm_options_update(self):
        # Load company options page
        url = reverse("polls:adm_options", args=[self.company.comp_slug])
        response = self.client.get(url)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "0123456789")         # this is the default value in tests for this field
        self.assertEqual(self.company.siret, "0123456789")

        # Options update
        self.company.siret = "987654321"
        comp_form = CompanyForm(instance=self.company)
        self.assertTrue(comp_form.is_valid())
        comp_form.save()
        company = Company.get_company(self.company.comp_slug)
        self.assertEqual(company.siret, "987654321")

In this case, the form is just empty!

I could consider my view works and go ahead, my problem is that I have a bug in another view and I would like to ensure I can build the test to find out the bug!

Many thanks in advance for your answers!

EDITS - Aug 30th
Following advices, I tried to use self.company.refresh_from_db() but it did not change the result.
A try was made to pass all fields in the self.client.post() but it fails as soon as a field is empty ('Cannot encode None as POST data' error message)
It also appeared that I created a 'dummy' company for test with empty mandatory fields... and it worked anyway. A matter of testing environment ? I changed this point but I wonder if the problem is not anywhere else...

EDITS - Sept 15th
Looking for someone available to provide me with new ideas, please =)

To ensure I understood the latest proposition, here is the complete code for the test:

def test_adm_options_update(self):
    # Load company options page
    url = reverse("polls:adm_options", args=[self.company.comp_slug])
    response = self.client.get(url)
    self.assertEqual(response.status_code, 200)
    self.assertContains(response, "0123456789")
    self.assertEqual(self.company.siret, "0123456789")

    # Apply changes
    company_data = copy.deepcopy(CompanyForm(instance=self.company).initial)
    company_data['siret'] = "987654321"
    response = self.client.post(
        reverse("polls:adm_options", args=[self.company.comp_slug]),
        company_data,
        )
    self.company.refresh_from_db()
    self.assertEqual(response.status_code, 200)
    self.assertContains(response, "987654321")
    self.assertNotContains(response, "0123456789")
    self.assertEqual(self.company.siret, "987654321")

Here is the function that creates the 'dummy' copany for tests:

def create_dummy_company(name):
    return Company.objects.create(
        company_name=name,
        comp_slug=slugify(name),
        logo=SimpleUploadedFile(name='logo.jpg', content=b'content', content_type='image/jpeg'),
        statut="SARL",
        siret="0123456789",
        address1="Rue des fauvettes",
        zip_code="99456",
        city='Somewhere',
        host="smtp.gmail.com",
        port=587,
        hname="test@polls.com",
        fax="toto",
    )
Christophe
  • 651
  • 4
  • 22

1 Answers1

6

In this case, you need to use refresh_from_db to "refresh" your object once the view and the form are done updating your object. This means that when you are currently asserting, you are using an "old snapshot" of self.company hence the failure on assertion, so you need to update it:

        # Options update
        response = self.client.post(
            reverse("polls:adm_options", args=[self.company.comp_slug]),
            {"siret": "987654321"}
        )
        ...
        self.company.refresh_from_db()
        self.assertEqual(self.company.siret, "987654321")

EDIT:

Figured out a way to make this work. Since the form requires that you put in all data, you can just pass the company instance to the same form, and access initial (which will serve as your request data).

You can then modify it with the changes you want, in this case for siret and logo:

from django.core.files.uploadedfile import SimpleUploadedFile

    def test(self):
        company_data = CompanyForm(instance=self.company).initial
        company_data['logo'] = SimpleUploadedFile(name='somefile', content=b'content', content_type='image/jpeg')
        company_data['siret'] = "987654321"

        response = self.client.post(
            reverse("polls:adm_options", args=[self.company.comp_slug]),
            company_data,
        )

        self.company.refresh_from_db()
        self.assertEqual(self.company.siret, "987654321")

This works and passes on my end with the same exact model you have.

Brian Destura
  • 11,487
  • 3
  • 18
  • 34
  • Thanks @bdbd for your answer! I thought it would be the right answer, but unfortunately it still does not work :-/ – Christophe Aug 29 '21 at 16:27
  • I see. Try to debug it and see if it reaches the view, the form validation is true, and if the form saves. Also you can try to use `self.company = Company.get_company(self.company.comp_slug)` instead of `refresh_from_db` before the assertion – Brian Destura Aug 29 '21 at 23:23
  • I tried to investigate deeper, and the form is not valid (I also arleady tried to use the get_company method). Actually, some fields are just empty! For 2 of them, they depend on a third one, so the related fields in the page are disabled: could the be have any impact? For the other ones, how could it happen that a GET retrieves information which is lost when the form is POST? – Christophe Aug 30 '21 at 05:47
  • Try to print what `request.POST` has, and also print `form.errors` to see the errors the form has after calling `form.is_valid()` – Brian Destura Aug 30 '21 at 05:51
  • I did it and errors come from empty mandatory fields... but I don't understand why they're empty! – Christophe Aug 30 '21 at 05:52
  • Yes, I added display features in the POST part. And I though again about the problem: the view actually works and my intend is to develop unit tests to prove it. These unit tests fail because the post form is partially empty whereas all information exist – Christophe Aug 30 '21 at 06:08
  • I see, but in the test you are only sending `siret`: `{"siret": "987654321"}`. When this view is working, does it work with just `siret`? – Brian Destura Aug 30 '21 at 06:13
  • Well, I'm not sure of this, the idea is that it's the only field I want to update. Am I suppose to resent all fields, or will this just update the form? If I am suppose to resent the whole values, how can I do that? – Christophe Aug 30 '21 at 06:15
  • Unfortunately yes. For now to make it work, you need to set `fields = ['siret', ]` in your form meta. I will try to check how to fix this later – Brian Destura Aug 30 '21 at 06:22
  • Thanks, I will look at it too. – Christophe Aug 30 '21 at 06:28
  • I tried it out and it seems you need to post all the data, otherwise set fields to just `['siret', ]` – Brian Destura Aug 30 '21 at 07:28
  • I made additional tests and passed some more info. If I send only NOT NULL values, it works (btw, I intended to create a Company where a not null value was empty and it worked anyway), but as soon as I try to add null fields, I get a TypeError: Cannot encode None as POST data. As far as it's for test, it's manageable, but it's not fully satisfying for me. And of course, I cannot deal with only one filed in the form meta, as far as all fields are displayed and supposed to be updated – Christophe Aug 30 '21 at 08:54
  • Thanks @BrianD for proposing me this new solution. Unfortunately, I get an error due to an ImageField: `AttributeError: 'ImageFieldFile' object has no attribute 'storage'`. I'll check the documentation for `copy`, that I didn't know, if there are some kind of limitations – Christophe Sep 15 '21 at 16:30
  • I made additional tests, I tried what is suggested in [this post dedicated to ImageField testing and mocking](https://stackoverflow.com/questions/26298821/django-testing-model-with-imagefield) but I was not able to run a test without error. I had the above `AttributeError`, but also errors concerning inexistent path or permission denied... I tried to use global app's path such as IMAGE_ROOT or MEDIA_ROOT with no success. Would my problem rely on this ImageField from the beginning? – Christophe Sep 15 '21 at 21:56
  • Can you share the model? – Brian Destura Sep 15 '21 at 23:38
  • Yes of course! I updated to OP to add complete model's definition – Christophe Sep 15 '21 at 23:52
  • Ok updated. Didn't know that you had an image field, and that requires some separate handling. See if it works on your end :) – Brian Destura Sep 16 '21 at 08:51
  • Thanks again @Brian for your time. Unfortunately, I have an error that I already got: `TypeError: Cannot encode None as POST data. Did you mean to pass an empty string or omit the value?`. I also added the code of the function that creates the company – Christophe Sep 16 '21 at 17:14
  • Ah this means that the data you passed to the post has `null`/`None` values for `street_num`, `address2` and `street_cplt`. This is not allowed in forms. In that case you need to set them to blank or `''` – Brian Destura Sep 16 '21 at 22:46
  • Yes it works!!! So many thanks for your help Brian! Just a last question: why am I supposed to put blank values here, whereas these fields are defined as nullable and blankable? Does it mean that, when I create the form, the information that goes from server to the client and back are automatically populated with blank? And what does blank mean for an integer such as `street_num`? – Christophe Sep 17 '21 at 06:20
  • And btw, instead of creating `company_data`, putting `self.company.__dict__` in the `POST` method works fine – Christophe Sep 17 '21 at 06:25
  • I can't find the relevant documentation, but form data has no concept of null or none, so it needs to be set to blank if it needs to be part of form data. In the case of `street_num`, since you set `blank=True`, it will just be set to blank – Brian Destura Sep 17 '21 at 06:27
  • Ah yes `__dict__` would work as well. But I was looking send and match what the form expects the data to be that's why I used `form.initial` but `__dict__` should also be fine – Brian Destura Sep 17 '21 at 06:28
  • I tried to set `street_num=''` and it raised an error; it's not a big deal for this test but I'm wondering how to test nullable fields within a model! But it's another story, thanks again – Christophe Sep 17 '21 at 06:30
  • Sure thing. You can probably post that as another question. If you plan to post it, please include the error – Brian Destura Sep 17 '21 at 06:31