What are Unit Tests for and what are the advantages?

Asked

Viewed 1,684 times

26

I saw some videos and articles on unit testing, learned the Qunit framework and some test situations.

Still I could not think of a practical use that is worth more than a common browser debugging or exception handling, etc.

When and how I should actually use a unit testing framework?

2 answers

31

Imagine the following scenario: You have that huge program with a couple of hundred modules.

And then you have to move something in the middle. Maybe it’s taking a class or a function and dividing it into two, changing the shape of some parameters, making some change in a certain structure, something like that.

How do you know that your change went right and didn’t break anything that was already working? Simple, it’s "just" testing. And how do you test? Just open up your program, start putting in data, and keep an eye out for him to behave as expected. It is also relevant to test for error situations when the system is fed incorrect data or is running in a problem environment to see if everything goes as expected. If you see that something broke unexpectedly, you have what is called regression.

But testing is not something easy, especially when the system is big. Also, doing all this manually is:

  • Very annoying, running manually always the same tests to make sure that nothing broke unexpectedly.

  • Subject to mistakes, because often you forget to test some situation or some special case, which is precisely what you broke.

  • Time consuming, because to test all the functionalities of the system, it takes a long time, mainly for a large system with hundreds of functionalities.

Well, how can we tackle these problems? All of these manual test problems are centered on the fact that the one person is testing. The work is repetitive, tiring and time consuming. What if we made a program that ran the test automatically?

So that’s where automated testing comes in! You write a program that will open the system features, log in with data and verify that the results match what was expected. Your automated test will also simulate error situations to see if your program reacts to it as expected. So:

  • Running the automated test is no longer boring. Just click a button, let it work on its own and at the end it says whether it all worked out right or not.

  • It is no longer subject to errors, as your automated test will always do the test the same way. So you don’t risk forgetting to test something.

  • The test is no longer time-consuming (at least not as long as a human), as he doesn’t have to wait for a flesh-and-blood being to type, drag the mouse or remember what the next thing to do in the test.

Ok, but automated testing and unit testing are slightly different things. What exactly is a unit test?

A well-designed code is modular and because of this it makes sense that you can test the modules independently of each other. In addition, a well-made modularization reduces the risks of you having changes in functionality cascading side effects where it is not expected. And that is what unit testing is: Unit testing consists of an automated test whose purpose is to run tests only on a small isolated unit of your system.

For example, let’s assume you used this javascript function:

function valida_data(dia, mes, ano) {
    return dia <= 31 && mes <= 12;
}

You may notice that there is something wrong with it. But let’s assume that it has gone unnoticed and in the middle of your huge system it is there. Then suddenly someone says that in the issue part of the invoice appeared a date of April 31st. You will then begin to do a lot of debug in the notes, in the database, to spend hours and hours on end trying to find the bug, until you finally arrive in this function. And then you fix for it:

function valida_data(dia, mes, ano) {
    var dias = [0, 31, 28, 31, 30, 31, 30, 31, 30, 30, 31, 30, 31];
    return dia <= dias[mes] && mes <= 12;
}

And that’s it, April 31st is correctly rejected.

A few months later, someone complains that there was an error in the XML integration of the database. You will search in XML and have a lot of crazy error messages in the log, keep tracking and debugging the whole night and discovers that it was something that went wrong on August 31st. After several hours of debug you return to function valida_data and finally realizes that for August is 30 and not 31.

Okay, you’ve already spent two nights cracking your head because of this function. Is there anything else wrong with it? Well, let’s create some tests to know:

function testa_valida_data() {
    if (!valida_data(1, 1, 2015)) throw new Error("Não aceitou 1 de janeiro");
    if (!valida_data(31, 1, 2015)) throw new Error("Não aceitou 31 de janeiro");
    if (valida_data(32, 1, 2015)) throw new Error("Aceitou 32 de janeiro");
    if (valida_data(30, 2, 2016)) throw new Error("Aceitou 30 de fevereiro");
    if (!valida_data(29, 2, 2016)) throw new Error("Não aceitou 29 de fevereiro");
    if (valida_data(29, 8, 2015)) throw new Error("Não aceitou 29 de agosto");
    if (valida_data(31, 3, 2015)) throw new Error("Não aceitou 31 de março");
    if (!valida_data(31, 4, 2015)) throw new Error("Aceitou 31 de abril");
    // ... Outros testes
}

If you run the function testa_valida_data(), she will throw an error! This then gives you the certainty that there is something wrong in the function valida_data(). Then you go into the function and move it. To know if what you did is right, just rotate the testa_valida_data() again. When the function testa_valida_data() not throw a mistake, then maybe the function valida_data be sure.

Why maybe? Because if the unit test passes, there is no guarantee that there is no more forgotten detail. On the other hand if it fails, then you are sure that there is something wrong and the test will already tell you what is wrong and where the error came from, saving a lot of time debug. Also, although the unit test can’t guarantee you that your code is correct, the more and the better the tests, the less likely it is that something that is wrong may have been forgotten.

What about Qunit? Qunit is a framework that gives you flexibility and manageability in testing. After all, if we keep things the way they are in testa_valida_data() and there are several mistakes, it will only show the first and stop. Also, if there are multiple functions or modules to test, we will still have to call the test functions manually. With Qunit this is mitigated. Take this example:

function valida_data(dia, mes, ano) {
    var dias = [0, 31, 28, 31, 30, 31, 30, 31, 30, 30, 31, 30, 31];
    return dia <= dias[mes] && mes <= 12;
}

QUnit.test("Testa dias normais", function(assert) {
    assert.ok(valida_data( 1,  1, 2015), "Aceitar 1 de janeiro");
    assert.ok(valida_data( 7,  8, 2015), "Aceitar 7 de agosto");
    assert.ok(valida_data(13, 12, 2015), "Aceitar 13 de dezembro");
});

QUnit.test("Testa últimos dias", function(assert) {
    assert.ok(valida_data(31,  1, 2015), "Aceitar 31 de janeiro");
    assert.ok(valida_data(28,  2, 2015), "Aceitar 28 de fevereiro");
    assert.ok(valida_data(31,  3, 2015), "Aceitar 31 de março");
    assert.ok(valida_data(30,  4, 2015), "Aceitar 30 de abril");
    assert.ok(valida_data(31,  5, 2015), "Aceitar 31 de maio");
    assert.ok(valida_data(30,  6, 2015), "Aceitar 30 de junho");
    assert.ok(valida_data(31,  7, 2015), "Aceitar 31 de julho");
    assert.ok(valida_data(31,  8, 2015), "Aceitar 31 de agosto");
    assert.ok(valida_data(30,  9, 2015), "Aceitar 30 de setembro");
    assert.ok(valida_data(31, 10, 2015), "Aceitar 31 de outubro");
    assert.ok(valida_data(30, 11, 2015), "Aceitar 30 de novembro");
    assert.ok(valida_data(31, 12, 2015), "Aceitar 31 de dezembro");
});

QUnit.test("Testa além dos últimos dias", function(assert) {
    assert.notOk(valida_data(32,  1, 2015), "Rejeitar 32 de janeiro");
    assert.notOk(valida_data(30,  2, 2015), "Rejeitar 30 de fevereiro");
    assert.notOk(valida_data(32,  3, 2015), "Rejeitar 32 de março");
    assert.notOk(valida_data(31,  4, 2015), "Rejeitar 31 de abril");
    assert.notOk(valida_data(32,  5, 2015), "Rejeitar 32 de maio");
    assert.notOk(valida_data(31,  6, 2015), "Rejeitar 31 de junho");
    assert.notOk(valida_data(32,  7, 2015), "Rejeitar 32 de julho");
    assert.notOk(valida_data(32,  8, 2015), "Rejeitar 32 de agosto");
    assert.notOk(valida_data(31,  9, 2015), "Rejeitar 31 de setembro");
    assert.notOk(valida_data(32, 10, 2015), "Rejeitar 32 de outubro");
    assert.notOk(valida_data(31, 11, 2015), "Rejeitar 31 de novembro");
    assert.notOk(valida_data(32, 12, 2015), "Rejeitar 32 de dezembro");
});

QUnit.test("Testa zeros e negativos", function(assert) {
    assert.notOk(valida_data(-1,  1, 2015), "Rejeitar dia negativo");
    assert.notOk(valida_data( 0,  1, 2015), "Rejeitar dia zero");
    assert.notOk(valida_data( 1,  0, 2015), "Rejeitar mês zero");
    assert.notOk(valida_data( 1, -1, 2015), "Rejeitar mês negativo");
    assert.notOk(valida_data( 0,  0, 2015), "Rejeitar mês e dia zero");
    assert.notOk(valida_data( 0, -1, 2015), "Rejeitar dia zero de mês negativo");
    assert.notOk(valida_data(-1,  0, 2015), "Rejeitar dia negativo e mês zero");
    assert.notOk(valida_data(-1, -1, 2015), "Rejeitar dia e mês negativo");
});

QUnit.test("Testa bissextos", function(assert) {
    assert.notOk(valida_data(29,  2, 2015), "Rejeitar 29 de fevereiro não-bissexto");
    assert.ok   (valida_data(29,  2, 2016), "Aceitar 29 de fevereiro bissexto");
    assert.ok   (valida_data(29,  2, 2000), "Aceitar 29 de fevereiro bissexto");
    assert.notOk(valida_data(29,  2, 1999), "Rejeitar 29 de fevereiro não-bissexto");
    assert.notOk(valida_data(29,  2, 1998), "Rejeitar 29 de fevereiro não-bissexto");
    assert.notOk(valida_data(29,  2, 1997), "Rejeitar 29 de fevereiro não-bissexto");
    assert.ok   (valida_data(29,  2, 1996), "Aceitar 29 de fevereiro bissexto");
    assert.notOk(valida_data(29,  2, 1900), "Rejeitar 29 de fevereiro não-bissexto");
    assert.notOk(valida_data(29,  2, 1800), "Rejeitar 29 de fevereiro não-bissexto");
    assert.notOk(valida_data(29,  2, 1700), "Rejeitar 29 de fevereiro não-bissexto");
    assert.ok   (valida_data(29,  2, 1600), "Aceitar 29 de fevereiro bissexto");
    assert.notOk(valida_data(29,  2, 2100), "Rejeitar 29 de fevereiro não-bissexto");
});

QUnit.test("Testa mês depois de dezembro", function(assert) {
    assert.notOk(valida_data(10, 13, 2015), "Rejeitar dia do mês 13.");
    assert.notOk(valida_data(10, 14, 2015), "Rejeitar dia do mês 14.");
    assert.notOk(valida_data( 0, 13, 2015), "Rejeitar dia zero do mês 13.");
    assert.notOk(valida_data( 0, 14, 2015), "Rejeitar dia zero do mês 14.");
    assert.notOk(valida_data(-1, 13, 2015), "Rejeitar dia negativo do mês 13.");
    assert.notOk(valida_data(-1, 14, 2015), "Rejeitar dia negativo do mês 14.");
    assert.notOk(valida_data(32, 13, 2015), "Rejeitar dia 32 do mês 13.");
    assert.notOk(valida_data(32, 14, 2015), "Rejeitar dia 32 do mês 14.");
});
<link rel="stylesheet" href="https://code.jquery.com/qunit/qunit-2.6.0.css">
<script src="https://code.jquery.com/qunit/qunit-2.6.0.js"></script>
<div id="qunit"></div>
<div id="qunit-fixture"></div>

If you run the above tests, you will see that many of them fail. The function valida_data() which I have provided is purposely flawed, and just click the button "Execute" up there to see that.

If you copy and paste this snippet somewhere else and change the function valida_data(), you can check if your changes are correct just by clicking a button. With this, you can keep changing it and quickly know if what you did is right or not, until all the tests pass. I leave this task as an (easy) exercise for the reader. :)

Let’s assume you changed the code of the function until all the tests pass. If some time after that (possibly years later), you have to change the function for some reason (e.g., found a better performing way), then how do you know if you haven’t messed up with anything or introduced a bug? Just click on the button "Execute" and see the result. If you have entered an error, it is very likely that it will appear there. If the test did not show any errors, your changes are likely to be valid and reliable. And so you can have a quick, thorough, efficient and detailed test with just one click.

Also, if years later someone finds a bug that was not covered in any test, all you need to do is add a test for the bug, and change the code to fix it. Your test ends up serving as a guard against regressions. Because if the bug comes back, the test will break and report this fact. And of course, if a new bug is introduced, the existing tests are highly likely to report it.

On the other hand, to be frank, there’s a downside: You need to write the test code, it doesn’t fall out of the sky! And writing test code takes some time, but you’ll save this time later with debugging that you nay you’ll need to do. In addition, having a wide and good coverage of tests is difficult, but even so the higher the coverage of the tests, the lower the probability that some change in the code breaks something unexpectedly.

And of course, it’s possible (and common good) that the tests are wrong, which results in tests that accept wrong code, or reject right code, or simply don’t properly test what they propose to test. It is also common that there are fragile tests, which break because of innocuous and harmless changes, or loose tests, which do not break even when harmful and dangerous changes occur. These problems with testing can have several causes, such as: poor quality of tested code; poor quality of test code; low test coverage over tested code; and confusing and poorly defined design requirements (How will you properly test something that even you don’t know exactly what to do?). Anyway, writing good tests also requires a certain experience, but to have experience, there is only one way: to put your hand in the dough and practice.

Link to get Qunit: https://qunitjs.com/

15

Unit Testing, or Unit Testing, aims to ensure that certain processing units (in general methods and functions) do not only what is expected of them, but that carry on doing just that when the system evolves. In other words, if a given function today receives the input X and produces the output Y, create a unit test where it ensures (assert) that this actually occurs helps ensure that, even when this function or one of its dependencies is changed in the future, this behavior remains.

Thus, unit testing of a system is a form of informal contract - in addition to the formal contracts imposed by a certain language (e.g., the F method receives three parameters, with types A, B and C, and returns a value with type D; class X implements interfaces Y and Z; etc.), it is often desirable - but difficult to obtain in practice - that there is also a contract in relation to semantics of the code. Several formal methods to ensure this semantics - for example, logical proofs of the correction of a given code, which demonstrate that it is a correct implementation of a given algorithm expressed in mathematical logic. Meanwhile, the cost to achieve such a level of accuracy does not always compensate...

On the other hand, it is relatively easy to produce examples of inputs and outputs which satisfy a given logic. These examples may not be sufficient to prove beyond any doubt that an implementation is correct, but if well chosen can give a good coverage of the most "interesting" cases (where a discontinuity in the output values is expected in relation to the inputs). A unit test may not prove that a semantics is correct, but he can disprove when it is not.

About when and how to use, I don’t know if I’m the best person to answer (the only thing I’m sure of is that me use less than it should...), but I can give some indications based on how I perceive the function of them:

  • If you did a "common debugging" and found invalid results - then fix them - it helps to create tests with these results so as to ensure that these problems do not recur in the future (i.e. these particular unit tests also would be regression testing);
  • If a function or class is dependent on several others, the consequences of an unexpected behavior change in it would be much greater than one that is not dependent on anyone - A small change in it can cause a very large number of bugs in other parts of the code. Thus, while ideally all code is well tested, greater attention should be paid to those parts that are reused more frequently;
  • Whether the implementation of an algorithm is complex - with many ifs, loops uncommon (i.e. more complex than "scroll through the X list"), etc - not only is it more likely to contain bugs, but it is quite possible that you yourself do not understand it 100% (despite having written it). In this case, it is not only good to create tests to ensure its correction, but also to test your limit cases:

    • Ensure that all lines of code are traversed in at least one test each;
    • If feasible, also ensure that each combo are activated in at least one test (e.g.: there are two ifs A and B, create a test in which he does not enter either of the two, one that he enters the two, one that enters A but not B and one that enters B but not A);
    • Find out which cases are "interesting" and produce a test for them. For example, if the function receives an integer, create a test by passing zero, another one, another minus one, another Integer.MAX_VALUE, etc. If it uses the sine of an angle in a calculation, make that angle 0, 90º, 180º, 270º, 360º and -90º (by adjusting the test parameters for this to occur). If she expects "a list with at least two elements" create a test where the list in two, another where she has three, another where she has one and the other where she is empty. Etc..
  • Reduce the "confounding variables" (confounding): if the unit you are trying to test depends on several others (e.g., a function that calls others, one class that inherits from another) look - within the capabilities of your language/platform, of course - replace these dependencies with mock-ups that always give the right result. It is quite undesirable that a drive test for X function fails not because X is incorrect but because Y which is called by X is.

    It is always good to create many tests for Y too, of course (see the second point above), but as already said these tests do not prove that a function is correct, just give examples. If all the tests for Y have passed, but X calls Y with a distinct parameter from all of them, which causes Y to fail, then we have a problem... If this happens (and from time to time, despite our best efforts), create new Y tests based on your X debugging output (see the first point above).

Finally, it doesn’t hurt to remember that these tests are worthless if you don’t run them. Always! Not once here, once there, but preferably every time you make any changes to your system. Some workflows require that these tests be performed on each compilation (when the compilation step is separate from the execution step) - which may be a problem if the tests take too long to complete, especially if it is testing again and again parts of the program that have not been changed. As for that, I’m afraid I have very little to suggest at this point (I don’t know any framework of unit tests that also make a decent management of dependencies - only by re-executing tests on units that really could have been affected by the last change). Perhaps someone with more experience in the subject can give us better news, I hope...

Browser other questions tagged

You are not signed in. Login or sign up in order to post.