Avançar para o conteúdo
Imagem com logotipo, contendo link para a página inicial
  • United Stated of America flag, representing the option for the English language.
  • Bandeira do Brasil, simbolizando a opção pelo idioma Português do Brasil.

Aprenda Programação: Testes e Depuração

Exemplos de uso de depuradores em quatro linguagens de programação: Python, Lua, GDScript e JavaScript.

Créditos para a imagem: Imagem criada pelo autor usando o programa Spectacle.

Pré-Requisitos

Na introdução sobre ambientes de desenvolvimento, indiquei Python, Lua e JavaScript como boas escolhas de linguagens de programação para iniciantes. Posteriormente, comentei sobre GDScript como opção para pessoas que tenham interesse em programar jogos digitais ou simulações. Para as atividades de introdução a programação, você precisará de, no mínimo, um ambiente de desenvolvimento configurado em uma das linguagens anteriores.

Caso queira experimentar programação sem configurar um ambiente, você pode usar um dos editores online que criei:

Contudo, eles não possuem todos os recursos dos interpretadores para as linguagens. Assim, cedo ou tarde, você precisará configurar um ambiente de desenvolvimento. Caso precise configurar um, confira os recursos a seguir.

Assim, se você tem um Ambiente Integrado de Desenvolvimento (em inglês, Integrated Development Environment ou IDE) ou a combinação de editor de texto com interpretador, você está pronto para começar. Os exemplos assumem que você saiba executar código em sua linguagem escolhida, como apresentado nas páginas de configuração.

Caso queira usar outra linguagem, a introdução provê links para configuração de ambientes para as linguagens C, C++, Java, LISP, Prolog e SQL (com SQLite). Em muitas linguagens, basta seguir os modelos da seção de experimentação para adaptar sintaxe, comandos e funções dos trechos de código. C e C++ são exceções, por requerem o uso de ponteiros para acesso à memória.

A Raiz do Problema

Praticamente programa minimamente complexo possui problemas. Problemas de software podem ser categorizados como defeitos, erros ou falhas; bugs, no termo popular.

Ao longo dos tópicos anteriores, mencionou-se alguns recursos e técnicas para depurar programas. Como erros são intrinsecamente parte da programação, é útil conhecer técnicas para identificá-los e resolvê-los. Existem ferramentas para depuração e teste de software. Depuradores, frameworks de teste, integração contínua, e sistemas de gerenciamento de controle de versões de código-fonte são alguns exemplos de ferramentas que podem aumentar a manutenibilidade de projetos.

Para começar, existem diferentes tipos e hierarquias de bugs em software. Por exemplo, bugs podem resultar de:

  1. Problemas de projeto ou de implementação. Nesse caso, o erro está no código que implementa a funcionalidade;
  2. Problema de uso de código. Nesse caso, o erro decorre da chamada ou uso incorreto de um código correto. Como mencionado em Bibliotecas, o uso de uma biblioteca pressupõe um contrato. Caso os pressupostos sejam violados, não há garantias de que a chamada a uma subrotina ou o uso de um registro seja válido;
  3. Problemas externos ao programa. Por exemplo, pode-se tentar usar um arquivo que não exista mais ou acessar um recurso online que esteja indisponível.

No primeiro caso, trata-se de um bug no projeto ou biblioteca. Para eliminá-lo, é preciso identificar o local em que ocorre e entender a causa. Ou seja, precisa-se de um trabalho investigativo (como de um detetive) para se examinar o resultado incorreto (ou travamento) obtido, para se chegar até a raiz do problema. Entendendo-se a causa originadora, pode-se corrigi-la. A correção é comumente chamada de patch, em analogia a um curativo.

No segundo caso, trata-se de um uso equivocado de Interfaces de Programação de Aplicações (Application Programming Interfaces ou API). A ordem dos parâmetros pode estar errada, um valor foi digitado incorretamente, ou passou-se a variável errada para uma subrotina. Em linguagens com tipagem estática, o compilador tende a alertar sobre erros de tipos incorretos. Em linguagens com tipagem dinâmica, isso não ocorre. Por isso, a situação tende a ser mais comum em tipagem dinâmica, especialmente quando usadas por iniciantes e/ou quando se usar uma nova biblioteca para um domínio o qual não se tenha muita experiência.

Nos dois primeiros anteriores, o bug pode ser fatal. Com sorte, ele travará o programa durante o desenvolvimento, revelando o problema antes de usuários ou usuários finais interagirem com sistema. Esse tende a ser o melhor caso possível, pois permite corrigir o problema antes dele afetar pessoas que utilizem o código-fonte ou programa.

O terceiro caso é diferente dos demais. Existe código que pode ser usado corretamente, mas falhar devido a fatores externos. Exemplos típicos são implementações que manipulem arquivos, acessem bancos de dados e/ou usem recursos de rede. Em geral, embora não se possa garantir o funcionamento, pode-se conhecer de antemão alguns (ou todos) os cenários adversos. Assim, é possível tratar o cenário adverso e manter o programa funcionando. Para isso, o código chamado deve retornar o erro identificado para que o código que fez a chamada (caller) possa tratá-lo da melhor forma possível.

Programas robustos dependem de bom tratamento de erros. Além de resultar em um sistema mais estável, tratamento de erros adequado pode tornar o sistema mais seguro explorando-se técnicas de programação defensiva. Esse é um diferencial importante entre uma pessoa iniciante em programação e uma profissional. Convém, pois, começar este tópico com estratégias para tratar erros.

Tratamento de Erros

Existem duas formas comuns de comunicar um desenvolvedor ou uma desenvolvedora que uma chamada ou uso de código falhou: retornar um código de erro ou lançar uma exceção. Isso permite que a implementação que, por exemplo, chamou uma subrotina possa tratar o erro identificado da melhor forma possível.

De fato, um bom programa tenta tratar erros da melhor forma possível sem comprometer o funcionamento, a estabilidade, e a responsividade do sistema. Quando possível, retoma-se o fluxo normal de operação. Em casos em que o término seja inevitável, deve-se tentar terminar o processo graciosamente, com uma mensagem de erro informativa e sem travar.

Os exemplos escreverão os erros na saída de erros padrão stderr, como apresentado em Arquivos e Serialização (Marshalling). Alguns interpretadores de linhas de comando e IDEs podem fornecer formatação diferente para a escrita dos erros. Além de erros, normalmente é possível formatar mensagens como avisos, como apresentado no mesmo tópico.

Códigos de Erro

Um forma tradicional de indicar o sucesso ou falha de uma chamada consiste no retorno de um valor especial. Na forma mais simples, isso pode ser feito retornado-se um valor impossível ou inválido para o processamento realizado. De fato, é comum que funções em bibliotecas retornem valores como -1 e null (ou similar) para indicar que a chamada não teve sucesso. Em funções implementadas dessa forma, é essencial verificar o valor obtido antes de usá-lo, para assegurar que o resultado seja válido. Logo, deve-se consultar a documentação da subrotina antes de usá-la, para se conhecer os possíveis efeitos adversos.

Contudo, isso nem sempre é possível retornar um valor arbitrário como código de erro. Em alguns casos, todo possível valor de um tipo de dados é potencialmente válido como resultado de uma operação. Em outros, pode ser necessário distinguir entre um valor inválido ou null de um erro retornado com valor inválido ou null. Por exemplo, o resultado calculado foi null ou null é o código de erro fornecido? O retorno pode ser ambíguo.

Uma estratégia diferente consiste em sempre fornecer um código de erro para indicar o sucesso ou erro da chamada. Na forma mais simples, isso pode ser feito retornando-se um valor lógico. A chamada de função teve sucesso se o retorno for Verdadeiro; caso seja Falso, algum problema certamente ocorreu.

Em linguagens que permitam o retorno de múltiplos valores em uma função, como Python e Lua, isso pode ser facilmente implementado.

import sys

def divida(dividendo, divisor):
    if (divisor == 0):
        return False, None

    return True, dividendo / divisor

for divisor in [2, 0]:
    sucesso, resultado = divida(1.0, divisor)
    if (sucesso):
        print(resultado)
    else:
        print("Divisão por zero.", file=sys.stderr)
function divida(dividendo, divisor)
    if (divisor == 0) then
        return false, null
    end

    return true, dividendo / divisor
end

for _, divisor in ipairs({2, 0}) do
    local sucesso, resultado = divida(1.0, divisor)
    if (sucesso) then
        print(resultado)
    else
        io.stderr:write("Divisão por zero.\n")
    end
end

Em linguagens que não permitam, pode-se retornar um registro ou um dicionário como alternativa para contornar a limitação.

function divida(dividendo, divisor) {
    if (divisor === 0) {
        return {
            sucesso: false,
            resultado: undefined
        }
    }

    return {
        sucesso: true,
        resultado: dividendo / divisor
    }
}

for (let divisor of [2, 0]) {
    let resultado = divida(1.0, divisor)
    if (resultado.sucesso) {
        console.log(resultado.resultado)
    } else {
        console.error("Divisão por zero.")
    }
}
import sys

class Resultado:
    def __init__(self, sucesso, resultado = None):
        self.sucesso = sucesso
        self.resultado = resultado

def divida(dividendo, divisor):
    if (divisor == 0):
        return Resultado(False)

    return Resultado(True, dividendo / divisor)

for divisor in [2, 0]:
    resultado = divida(1.0, divisor)
    if (resultado.sucesso):
        print(resultado.resultado)
    else:
        print("Divisão por zero.", file=sys.stderr)
function divida(dividendo, divisor)
    if (divisor == 0) then
        return {
            sucesso = false,
            resultado = null
        }
    end

    return {
        sucesso = true,
        resultado = dividendo / divisor
    }
end

for _, divisor in ipairs({2, 0}) do
    local resultado = divida(1.0, divisor)
    if (resultado.sucesso) then
        print(resultado.resultado)
    else
        io.stderr:write("Divisão por zero.\n")
    end
end
extends Node

class Resultado:
    var sucesso
    var resultado

    func _init(sucesso, resultado = null):
        self.sucesso = sucesso
        self.resultado = resultado

func divida(dividendo, divisor):
    if (divisor == 0):
        return Resultado.new(false)

    return Resultado.new(true, dividendo / divisor)

func _ready():
    for divisor in [2, 0]:
        var resultado = divida(1.0, divisor)
        if (resultado.sucesso):
            print(resultado.resultado)
        else:
            printerr("Divisão por zero.")

As implementações alternam entre o uso de registros e dicionários; ambas as opções são válidas. Para versões mais robustas, poder-se-ia verificar o tipo da variável. Caso não fosse numérico, retornar-se-ia erro.

No caso da versão mais robusta hipotética, pode ser interessante especificar o tipo ou a causa do erro. Para isso, pode-se retornar um valor inteiro (ou valor de uma enumeração) como código de erro mais granular. Isso é feito, por exemplo, por Godot Engine, que define OK com valor 0 e uma série de erros com o prefixo ERR_ (documentação); por exemplo, retorna-se ERR_OUT_OF_MEMORY caso não haja memória livre suficiente para uma alocação.

const OK = 0
// Erro genérico.
const ERRO = 1
// Erros específicos.
const ERRO_DIVISAO_POR_ZERO = 2
const ERRO_TIPO_INVALIDO = 3

function divida(dividendo, divisor) {
    if ((typeof(dividendo) !== "number") || (typeof(divisor) !== "number")) {
        return {
            erro: ERRO_TIPO_INVALIDO,
            resultado: undefined
        }
    }

    if (divisor === 0) {
        return {
            erro: ERRO_DIVISAO_POR_ZERO,
            resultado: undefined
        }
    }

    return {
        erro: OK,
        resultado: dividendo / divisor
    }
}

for (let divisor of [2, 0, "Franco"]) {
    let resultado = divida(1.0, divisor)
    if (resultado.erro === OK) {
        console.log(resultado.resultado)
    } else if (resultado.erro === ERRO_DIVISAO_POR_ZERO) {
        console.error("Divisão por zero.")
    } else if (resultado.erro === ERRO_TIPO_INVALIDO) {
        console.error("Tipo inválido passado como parâmetro.")
    } else {
        console.error("Outro erro.")
    }
}
import sys
from typing import Final

OK: Final = 0
# Erro genérico.
ERRO: Final = 1
# Erros específicos.
ERRO_DIVISAO_POR_ZERO: Final = 2
ERRO_TIPO_INVALIDO: Final = 3

class Resultado:
    def __init__(self, erro, resultado = None):
        self.erro = erro
        self.resultado = resultado

def divida(dividendo, divisor):
    if ((not isinstance(dividendo, (int, float))) or (not isinstance(divisor, (int, float)))):
        return Resultado(ERRO_TIPO_INVALIDO)

    if (divisor == 0):
        return Resultado(ERRO_DIVISAO_POR_ZERO)

    return Resultado(OK, dividendo / divisor)

for divisor in [2, 0, "Franco"]:
    resultado = divida(1.0, divisor)
    if (resultado.erro == OK):
        print(resultado.resultado)
    elif (resultado.erro == ERRO_DIVISAO_POR_ZERO):
        print("Divisão por zero.", file=sys.stderr)
    elif (resultado.erro == ERRO_TIPO_INVALIDO):
        print("Tipo inválido passado como parâmetro.", file=sys.stderr)
    else:
        print("Outro erro.", file=sys.stderr)
local OK <const> = 0
-- Erro genérico.
local ERRO <const> = 1
-- Erros específicos.
local ERRO_DIVISAO_POR_ZERO <const> = 2
local ERRO_TIPO_INVALIDO <const> = 3

function divida(dividendo, divisor)
    if ((not (type(dividendo) == "number")) or (not (type(divisor) == "number"))) then
        return {
            erro = ERRO_TIPO_INVALIDO,
            resultado = null
        }
    end

    if (divisor == 0) then
        return {
            erro = ERRO_DIVISAO_POR_ZERO,
            resultado = null
        }
    end

    return {
        erro = OK,
        resultado = dividendo / divisor
    }
end

for _, divisor in ipairs({2, 0, "Franco"}) do
    local resultado = divida(1.0, divisor)
    if (resultado.erro == OK) then
        print(resultado.resultado)
    elseif (resultado.erro == ERRO_DIVISAO_POR_ZERO) then
        print("Divisão por zero.")
    elseif (resultado.erro == ERRO_TIPO_INVALIDO) then
        io.stderr:write("Tipo inválido passado como parâmetro.\n")
    else
        io.stderr:write("Outro erro.\n")
    end
end
extends Node

const OK = 0
# Erro genérico.
const ERRO = 1
# Erros específicos.
const ERRO_DIVISAO_POR_ZERO = 2
const ERRO_TIPO_INVALIDO = 3

class Resultado:
    var erro
    var resultado

    func _init(erro, resultado = null):
        self.erro = erro
        self.resultado = resultado

func divida(dividendo, divisor):
    if ((not (typeof(dividendo) == TYPE_INT or typeof(dividendo) == TYPE_REAL)) or
        (not (typeof(divisor) == TYPE_INT or typeof(divisor) == TYPE_REAL))):
        return Resultado.new(ERRO_TIPO_INVALIDO)

    if (divisor == 0):
        return Resultado.new(ERRO_DIVISAO_POR_ZERO)

    return Resultado.new(OK, dividendo / divisor)

func _ready():
    for divisor in [2, 0, "Franco"]:
        var resultado = divida(1.0, divisor)
        if (resultado.erro == OK):
            print(resultado.resultado)
        elif (resultado.erro == ERRO_DIVISAO_POR_ZERO):
            printerr("Divisão por zero.")
        elif (resultado.erro == ERRO_TIPO_INVALIDO):
            printerr("Tipo inválido passado como parâmetro.")
        else:
            printerr("Outro erro.")

Qualquer que seja o caso do valor de retorno, o uso da função torna-se um pouco menos conveniente, pois não é possível usar o valor retornado diretamente na verificação de erro.

Em linguagens de programação que permitem a passagem de parâmetros de quaisquer tipos como referência (como C e C++), pode-se retornar um valor lógico e modificar o valor do parâmetro por referência em caso de sucesso. Embora isso não possa ser feito para tipos primitivos em algumas linguagens de programação, normalmente isso é possível para alguns tipos compostos (como vetores e dicionários).

// Linux: gcc main.c && ./a.out

#include <stdio.h>

#define FALSE 0
#define TRUE 1

int divida(float* resultado, float dividendo, float divisor) {
    if (divisor == 0) {
        return FALSE;
    }

    *resultado = dividendo / divisor;
    return TRUE;
}

int main() {
    const float divisores[] = {2.0, 0.0};
    for (int indice = 0; indice < 2; ++indice) {
        float divisor = divisores[indice];
        float resultado;
        if (divida(&resultado, 1.0, divisor)) {
            printf("%f\n", resultado);
        } else {
            printf("Divisão por zero.\n");
        }
    }

    return 0;
}
// Linux: g++ main.cpp && ./a.out

#include <array>
#include <iostream>

bool divida(float& resultado, float dividendo, float divisor) {
    if (divisor == 0) {
        return false;
    }

    resultado = dividendo / divisor;
    return true;
}

int main() {
    const std::array<float, 2> divisores{2.0, 0.0};
    for (float divisor: divisores) {
        float resultado;
        if (divida(resultado, 1.0, divisor)) {
            std::cout << resultado << std::endl;
        } else {
            std::cout << "Divisão por zero." << std::endl;
        }
    }

    return 0;
}
function divida(resultado, dividendo, divisor) {
    if (divisor === 0) {
        return false
    }

    resultado[0] = dividendo / divisor
    return true
}

for (let divisor of [2, 0]) {
    let resultado = [0]
    if (divida(resultado, 1.0, divisor)) {
        console.log(resultado[0])
    } else {
        console.error("Divisão por zero.")
    }
}
import sys

def divida(resultado, dividendo, divisor):
    if (divisor == 0):
        return False

    resultado[0] = dividendo / divisor
    return True

for divisor in [2, 0]:
    resultado = [0]
    if (divida(resultado, 1.0, divisor)):
        print(resultado[0])
    else:
        print("Divisão por zero.", file=sys.stderr)
function divida(resultado, dividendo, divisor)
    if (divisor == 0) then
        return false
    end

    resultado[0] = dividendo / divisor
    return true
end

for _, divisor in ipairs({2, 0}) do
    local resultado = {0}
    if (divida(resultado, 1.0, divisor)) then
        print(resultado[0])
    else
        io.stderr:write("Divisão por zero.\n")
    end
end
extends Node

func divida(resultado, dividendo, divisor):
    if (divisor == 0):
        return false

    resultado[0] = dividendo / divisor
    return true

func _ready():
    for divisor in [2, 0]:
        var resultado = [0]
        if (divida(resultado, 1.0, divisor)):
            print(resultado[0])
        else:
            printerr("Divisão por zero.")

Evidentemente, o uso da abordagem anterior para tipos primitivos em linguagens como JavaScript, Python, Lua e GDScript não é algo recomendável nem idiomático. Contudo, para operações com tipos passados por referência, ela é válida.

Exceções (Exceptions)

A segunda abordagem de indicar erros consiste no uso de exceções. Uma exceção é lançada pelo código que identifica o erro. Qualquer parte do código que esteja em níveis anteriores da pilha de execução podem tratá-la. Em potencial, o tratamento pode lançar a exceção novamente, para que outras partes possam continuar o tratamento.

JavaScript (documentação; hierarquia) e Python são linguagens com suporte a exceções (documentação).

function divida(dividendo, divisor) {
    if (divisor === 0) {
        throw "Divisão por zero."
    }

    return (dividendo / divisor)
}

for (let divisor of [2, 0]) {
    try {
        let resultado = divida(1.0, divisor)
        console.log(resultado)
    } catch (excecao) {
        console.error(excecao)
    }
}
import sys

def divida(dividendo, divisor):
    if (divisor == 0):
        raise Exception("Divisão por zero")

    return (dividendo / divisor)

for divisor in [2, 0]:
    try:
        resultado = divida(1.0, divisor)
        print(resultado)
    except Exception as excecao:
        print(excecao, file=sys.stderr)

Quando se trabalha com exceções, não é recomendado usar a classe Exception ou similar básica. Normalmente é melhor seguir as hierarquias definidas pela linguagem ou criar as próprias exceções. Isso permite tratar a exceção recebida com maior granularidade, dependendo do tipo de erro que ocorreu. Os links para documentações anteriores fornecem as hierarquias básicas para JavaScript e Python.

class ErroDivisaoPorZero extends RangeError {
    constructor(mensagem) {
        super(mensagem)
    }
}

function divida(dividendo, divisor) {
    if ((typeof(dividendo) !== "number") || (typeof(divisor) !== "number")) {
        throw new TypeError("Tipo inválido para parâmetro.")
    }

    if (divisor === 0) {
        throw new ErroDivisaoPorZero("Tentativa de divisão por zero.")
    }

    return (dividendo / divisor)
}

for (let divisor of [2, 0, "Franco"]) {
    try {
        let resultado = divida(1.0, divisor)
        console.log(resultado)
    } catch (excecao) {
        // console.trace()
        if (excecao instanceof ErroDivisaoPorZero) {
            console.error(excecao)
        } else if (excecao instanceof TypeError) {
            console.error(excecao)
        } else {
            console.error(excecao)
        }
    }
}
import sys
import traceback

def divida(dividendo, divisor):
    if ((not isinstance(dividendo, (int, float))) or (not isinstance(divisor, (int, float)))):
        raise TypeError("Tipo inválido para parâmetro.")

    if (divisor == 0):
        raise ZeroDivisionError("Tentativa de divisão por zero.")

    return (dividendo / divisor)

for divisor in [2, 0, "Franco"]:
    try:
        resultado = divida(1.0, divisor)
        print(resultado)
    except ZeroDivisionError as excecao:
        print(excecao, file=sys.stderr)
        print(traceback.format_exc())
    except TypeError as excecao:
        print(excecao, file=sys.stderr)
        print(traceback.format_exc())
    except Exception as excecao:
        print(excecao, file=sys.stderr)
        print(traceback.format_exc())

Quando se usa exceções, pode ser útil escrever a pilha de execução como um traceback. Em Python, o módulo Traceback (documentação) permite escrever a pilha de execução. Em JavaScript, isso pode ser feito usando-se console.trace() (documentação), embora console.error() também o faça por padrão.

Algumas implementações de exceções fornecem uma terceira cláusula chamada finally. O código em finally é executado tanto caso ocorra uma exceção, quanto o código funcione normalmente. Isso é útil, por exemplo, quando se queira fechar um arquivo independentemente do resultado do código chamado.

class ErroDivisaoPorZero extends RangeError {
    constructor(mensagem) {
        super(mensagem)
    }
}

function divida(dividendo, divisor) {
    if ((typeof(dividendo) !== "number") || (typeof(divisor) !== "number")) {
        throw new TypeError("Tipo inválido para parâmetro.")
    }

    if (divisor === 0) {
        throw new ErroDivisaoPorZero("Tentativa de divisão por zero.")
    }

    return (dividendo / divisor)
}

for (let divisor of [2, 0, "Franco"]) {
    try {
        let resultado = divida(1.0, divisor)
        console.log(resultado)
    } catch (excecao) {
        // console.trace()
        if (excecao instanceof ErroDivisaoPorZero) {
            console.error(excecao)
        } else if (excecao instanceof TypeError) {
            console.error(excecao)
        } else {
            console.error(excecao)
        }
    } finally {
        console.log("Finally")
    }
}
import sys
import traceback

def divida(dividendo, divisor):
    if ((not isinstance(dividendo, (int, float))) or (not isinstance(divisor, (int, float)))):
        raise TypeError("Tipo inválido para parâmetro.")

    if (divisor == 0):
        raise ZeroDivisionError("Tentativa de divisão por zero.")

    return (dividendo / divisor)

for divisor in [2, 0, "Franco"]:
    try:
        resultado = divida(1.0, divisor)
        print(resultado)
    except ZeroDivisionError as excecao:
        print(excecao, file=sys.stderr)
        print(traceback.format_exc())
    except TypeError as excecao:
        print(excecao, file=sys.stderr)
        print(traceback.format_exc())
    except Exception as excecao:
        print(excecao, file=sys.stderr)
        print(traceback.format_exc())
    finally:
        print("Finally")

O uso de exceções pode tornar a implementação mais limpa que o uso de códigos de erro. No geral, contudo, a escolha entre as abordagens pode depender de preferências pessoais ou de requisitos para o projeto. Por exemplo, o uso de exceções pode gerar overhead indesejável em sistemas nos quais desempenho seja fundamental ou a memória seja limitada. Overhead refere-se a custos computacionais necessários para a execução de um código, mas que poderiam ser evitados com diferentes implementações (potencialmente menos práticas, e mais complexas e restritas). Em outras palavras, overhead é comumente sinônimo de recursos desperdiçados.

Traceback em Lua e GDScript

Lua e GDScript não fornecem suporte a exceções. Contudo, ainda é possível escrever um traceback. Isso foi feito anteriormente, por exemplo, em Arquivos e Serialização (Marshalling).

function divida(dividendo, divisor)
    if (divisor == 0) then
        error("Divisão por zero.")
    end

    return (dividendo / divisor)
end

for _, divisor in ipairs({2, 0}) do
    local resultado = divida(1.0, divisor)
    print(resultado)
end
extends Node

func divida(dividendo, divisor):
    if (divisor == 0):
        push_error("Divisão por zero")
        assert(false)

    return (dividendo / divisor)

func _ready():
    for divisor in [2, 0]:
        var resultado = divida(1.0, divisor)
        print(resultado)

No caso de Lua, error() permite alterar o local em que se provê informações sobre a pilha de execução em caso de erro.

function divida(dividendo, divisor)
    if (divisor == 0) then
        error("Divisão por zero.", 2)
    end

    return (dividendo / divisor)
end

for _, divisor in ipairs({2, 0}) do
    local resultado = divida(1.0, divisor)
    print(resultado)
end

Como o valor 2 em error(), o interpretador exibe o local da chamada como início da pilha de execução. Caso se use o valor 0, a chamada não escreve a linha que gerou o erro.

Logging: Registrando Erros

Para projetos em desenvolvimento, é comum escrever mensagens em um console ou terminal. Subrotinas como console.log() e print() são convenientes para esse propósito.

Para programas distribuídos para usuárias e usuários finais, pode ser conveniente salvar as mensagens em um arquivo ao invés de escrevê-las em um terminal. Como mencionado em Arquivos e Serialização (Marshalling), arquivos de texto são comumente usados para logging. Isso permite registrar de forma persistente erros, avisos e/ou outras informações relevantes em um arquivo.

Em algumas linguagens de programação, é possível redirecionar stdout (saída padrão) e stderr (saída de erro padrão) para um arquivo. Isso pode ser feito de duas formas:

  1. Em Linha de Comando, usando recursos como > arquivo.txt para redirecionar a saída. > arquivo.txt redireciona a saída padrão, algo que também pode ser feito usando-se 1> arquivo.txt. Para redirecionar a saída de erro padrão, deve-se usar 2>. Para redirecionar ambos, pode-se usar &> arquivo.txt. Outra possibilidade é redirecionar stderr para stdout, depois stdout para um arquivo. Isso é feito como 2>&1 > arquivo.txt;
  2. Com recursos fornecidos pela biblioteca padrão da linguagem. Algumas implementações permitem escolher um arquivo ao invés de usar os padrões.

A primeira abordagem é externa ao programa. A segunda é interna ao programa; ela pode, pois, ser definida no código-fonte.

// node script.mjs

import * as fs from "fs"

let saida = fs.createWriteStream("saida.txt")
let saida_erro = fs.createWriteStream("erro.txt")

// Para usos simples:
// process.stdout.write = saida.write.bind(saida)
// process.stderr.write = saida_erro.write.bind(saida_erro)
// console.log("Olá, meu nome é Franco!")
// console.error("Erro: Meu nome não é Franco?")

// Para uma solução mais sofisticada:
import { Console } from "console"
console = new Console(saida, saida_erro)
console.log("Olá, meu nome é Franco!", 1, 2, 3, true)
console.error("Erro: Meu nome não é Franco?")

saida.close()
saida_erro.close()
import sys

sys.stdout = open("saida.txt", "w")
sys.stderr = open("erro.txt", "w")

print("Olá, meu nome é Franco!")
print("Erro: Meu nome não é Franco?", file=sys.stderr)

sys.stdout.close()
sys.stderr.close()
io.stdout = io.open("saida.txt", "w")
io.stderr = io.open("erro.txt", "w")

function print(...)
    local argumentos = {...}
    for indice, argumento in ipairs(argumentos) do
        if (indice > 1) then
            io.stdout:write("\t")
        end

        io.stdout:write(argumento)
    end

    io.stdout:write("\n")
    io.stdout:flush()
end

print("Olá, meu nome é Franco!", 1, 2, 3, "foo")
io.stdout:write("Olá, meu nome é Franco!")
io.stderr:write("Erro: Meu nome não é Franco?")

io.stdout:close()
io.stderr:close()

Com a configuração, a saída será escrita nos arquivos saida.txt e erro.txt, ao invés de no console (terminal).

No caso de JavaScript, a solução usa Node.js para uso em console ou terminal. A solução usa a biblioteca fs (documentação) e, opcionalmente, console (documentação). No caso de JavaScript para navegadores, uma alternativa é usar o console embutido. Caso se clique com o botão direito na área de saída, uma das opções será para salvar as mensagens.

No caso de Lua, a implementação define uma função variádica (variadic ou var arg) para escrever cada um dos argumentos recebidos por print() para a saída redirecionada. Uma função variádica pode ter um número variável de argumentos, por isso o nome var arg (documentação). Subrotinas como print() são comumente variádicas; por isso é possível usar variações como print("Olá") e print("Olá", " meu ", "nome é ", "Franco").

Em GDScript, a saída pode ser configurada no editor, como configuração do projeto (documentação). A opção está em Projeto (Project), Configurações do Projeto... (Project Settings...), Geral (General), Logging. Para habilitar a saída em arquivo, deve-se marcar Enable File Logging e escolher um arquivo em Log Path. O arquivo será gerado em user://; para encontrá-lo, convém consultar a documentação, pois o diretório varia de acordo com o sistema operacional (documentação):

  • Windows: %APPDATA%/NomeProjeto/;
  • Linux e macOS: ~/.local/share/godot/app_userdata/NomeProjeto/.

NomeProjeto é o nome escolhido para o projeto em Projeto (Project), Configurações do Projeto... (Project Settings...), Application, Config, Name.

Em linguagens de programação com funções de ordem alta (high-order functions), também é possível redefinir print() e console.log().

Testes Automatizados

Duas abordagens para testes automatizados de código-fonte foram comentadas ao longo de Aprenda Programação: asserções e teste unitário.

Asserções (Assertions)

O uso de asserções foi comentado em Subrotinas (Funções e Procedimentos) e Registros (Structs ou Records).

let valor = 1
console.assert(valor % 2 === 0, "valor deve ser par.")
valor = 1
assert valor % 2 == 0, "valor deve ser par."
local valor = 1
assert(valor % 2 == 0, "valor deve ser par.")
extends Node

func _ready():
    var valor = 1
    assert(valor % 2 == 0, "valor deve ser par.")

A condição testada deve resultar em Verdadeiro, pois se realiza uma assunção sobre o valor verificado. Um resultado Falso significa que o valor fornecido não corresponde ao esperado; ou seja, trata-se um uso incorreto da API.

Como comentado nos tópicos citados, o uso de assert() normalmente é válido apenas durante o desenvolvimento. Em versões otimizadas, assert() normalmente é desabilitado. Assim, a verificação de erros em tempo de uso deve ser feita usando Estruturas de condição (ou condicionais ou de seleção).

Teste Unitário (Unit Test)

O uso de teste unitário foi comentado em Subrotinas (Funções e Procedimentos) e Bibliotecas. O exemplo fornecido em Bibliotecas é prático, usando um framework para testes em GDScript.

Linguagens de programação podem possuir bibliotecas para teste unitário como parte da biblioteca padrão e/ou como bibliotecas externas.

Por exemplo, Python fornece o módulo unittest (documentação) como parte da biblioteca padrão.

JavaScript não possui biblioteca padrão para teste unitário. Bibliotecas externas podem suportar um navegador e/ou a um interpretador de linha de comando (como Node.js). Dois frameworks populares para teste unitário são Mocha e Jest.

Assim com JavaScript, Lua não fornece uma biblioteca por padrão, embora existem várias opções como bibliotecas externas. Como de costume, lua-users.org lista algumas opções.

Análise de Cobertura (Code Coverage)

Quando se trabalha com teste unitário, uma métrica importante é a cobertura de testes. A cobertura é a razão das linhas de código testadas pelo total de linhas de código do projeto. Evidentemente, o valor ideal para a cobertura é 100%. Quanto mais próximo de 100%, melhor a cobertura.

Frameworks de teste unitário podem fornecer relatórios de cobertura. Isso é particularmente útil porque permite identificar partes do projeto que não possuem testes automáticos. A criação de testes que possam verificar essas partes aumenta a taxa de cobertura, preenchendo antigas lacunas de linhas de código sem testes.

Alguns IDEs possuem funcionalidades ou extensões para a integração com frameworks testes unitários e análise de cobertura. Isso permite visualizar linhas atualmente cobertas (ou não) por testes, algo que também pode facilitar a priorização da introdução de novos testes em partes críticas do projeto.

Testes Manuais e/ou de Inspeção

Algumas abordagens para testes manuais foram mencionadas ao longo de Aprenda Programação. Como parte da evolução na carreira de programação, convém aperfeiçoar abordagens e técnicas para testar um programa durante o desenvolvimento.

Abordagens como Test-Driven Development (TDD) podem ser particularmente efetivas para auxiliar no desenvolvimento de software com maior qualidade. Ainda assim, técnicas mais simples podem ser convenientes durante atividades de programação.

Teste Usando Impressão para Inspeção de Valores

A primeira abordagem comentada para testes consiste no uso de subrotinas ou comandos de impressão para inspeção de valores. A abordagem rudimentar e espartana consta desde o tópico Ambientes de Desenvolvimento: Preparando Seu Computador para a Criação de Programas.

Embora simples, ela é comumente necessária para a depuração de sistemas de tempo real. Existem bugs que ocorrem apenas em tempo real; a inserção de um depurador pode modificar tempos de execução de forma a tornar o problema irreprodutível em um ambiente de teste.

Além disso, nem toda linguagem de programação possui um bom depurador. Em outros casos, a escolha de um depurador pode depender de sistema operacional ou da compra de um sistema proprietário (que pode ser caro). Em situações assim, o teste usando impressão de valores pode ser a única alternativa.

De forma mais otimista, um benefício é que a abordagem funciona em qualquer linguagem de programação e/ou framework. Sabendo-se escrever um Olá, mundo! na linguagem, sabe-se usar print() para a depuração de programas na linguagem.

Alguns programadores defendem o uso da abordagem mesmo quando existem outras opções. Uma opinião antiga, porém famosa (alerta de linguagem potencialmente ofensiva) é de Linus Torvalds, criador do Linux, sobre o uso do depurador kdb para a depuração do o kernel do sistema. Convém salientar o contexto de uso para o kernel; na mesma mensagem, Torvalds comenta favoravelmente sobre o uso do depurador gdb.

O sumário executivo da linguagem Python também defende que existem situações na qual a forma mais rápida de depurar um programa consiste em usar alguns comandos de impressão.

Como habitualmente, a opinião do autor deste website é que as abordagens são ferramentas. Existem técnicas mais pertinentes a um contexto que em outros. Conhecendo-se várias, pode-se aplicar a melhor para uma determinada situação. Portanto, testes com impressão são uma boa ferramenta para se conhecer e dominar.

Teste de Mesa

A abordagem de teste de mesa introduzida em Estruturas de repetição (ou laços ou loops) também continua válida. Contudo, deste tópico em diante, convém usar um depurador ao invés de papel para a realização do teste com o programa em execução em um computador.

Depuradores (Debuggers)

Um bom critério para a escolha de um IDE é optar por aquele que fornece o melhor depurador (ou a melhor integração com um depurador). De fato, essa foi a recomendação do autor em Ambientes de Desenvolvimento: Preparando Seu Computador para a Criação de Programas.

Os IDEs Thonny para Python, ZeroBrane Studio para Lua (documentação), e o editor de Godot Engine (documentação) possuem depuradores embutidos. Navegadores de Internet para computadores de mesa como Firefox (documentação) também possuem um depurador embutido, como parte das Ferramentas para Desenvolvimento Web.

Entretanto, talvez ironicamente, em nenhum momento sugeriu-se usar um depurador durante os tópicos anteriores. Isso se deve, em parte, a fluxogramas e linguagens de programação visual, que permitiram visualizar a execução de programas durante conceitos fundamentais como repetições e condições. Como próximos tópicos introduzirão temáticas e exemplos mais complexos, convém introduzir depuradores oficialmente por meio dos principais recursos.

Visualização de Funcionalidades em Imagens

As próxima subseções provêm imagens com a localização de recursos de depuradores que serão descritos nas próximas seções.

Depurador de Firefox para JavaScript

Depurador de Firefox para JavaScript. A imagem possui alguns números com funcionalidades providas na interface. Os números são descritos em texto após esta imagem.
  1. Aba de depuração;
  2. Script em depuração;
  3. Executar / continuar;
  4. Step over;
  5. Step in;
  6. Step out;
  7. Breakpoint ativo;
  8. Lista de breakpoints;
  9. Pilha de execução;
  10. Janela para watch;
  11. Valor de variável em hover (cursor do mouse sobre a variável).

Depurador de Thonny para Python

Depurador de Thonny para Python. A imagem possui alguns números com funcionalidades providas na interface. Os números são descritos em texto após esta imagem.
  1. Iniciar depurador;
  2. Step over;
  3. Step in;
  4. Step out;
  5. Continuar;
  6. Parar;
  7. Breakpoint ativo;
  8. Pilha de execução;
  9. Variáveis locais e globais;
  10. Variáveis em um nível da pilha.

Depurador de ZeroBrane Studio para Lua

Depurador de ZeroBrane Studio para Lua. A imagem possui alguns números com funcionalidades providas na interface. Os números são descritos em texto após esta imagem.
  1. Exibir pilha de execução;
  2. Exibir janela Watch;
  3. Executar / continuar;
  4. Step in;
  5. Step over;
  6. Step out;
  7. Breakpoint ativo;
  8. Pilha de execução;
  9. Janela para watch;
  10. Valor de variável em hover (cursor do mouse sobre a variável).

Depurador de Godot Engine para GDScript

Depurador de Godot Engine para GDScript. A imagem possui alguns números com funcionalidades providas na interface. Os números são descritos em texto após esta imagem.
  1. Aba de depuração;
  2. Aba de depuração;
  3. Executar;
  4. Step in;
  5. Step over;
  6. Parar;
  7. Continuar;
  8. Breakpoint ativo;
  9. Pilha de execução;
  10. Variáveis locais e atributos da classe;
  11. Valor de variável em hover (cursor do mouse sobre a variável).

Execução de Código em um Depurador

Depuradores permitem executar o código-fonte de um programa passo-a-passo, ou seja, linha a linha. A execução linha a linha é similar à execução simulada em um teste de mesa. A diferença é que o computador computa o valor das variáveis e encarrega-se de avançar para a próxima instrução do programa. Assim, a execução de um programa com um depurador pode ser similar a simulação de um teste de mesa automatizado.

As principais funcionalidades para execução de código usando um depurador incluem:

  • Executar ou run: executa o programa normalmente, do início ao fim. Caso se defina breakpoints (que serão comentados na próxima seção), o programa executa até o próximo breakpoint ativo que for atingido pela execução;
  • Parar ou stop: termina o programa. O término pode ser prematuro, ou seja, pode-se terminar o programa antes da última instrução ou de uma chamada como exit() ou quit();
  • Pausar, pause ou break: pausa a execução do programa na última linha executada;
  • Continuar ou resume: continua a execução do ponto de pausa. Continuar é comumente mesclado com executar;
  • Passo por cima ou step over: avança para a próxima linha de código, sem "entrar" no código executado da linha atual. Por exemplo, caso se tenha uma chamada de função, step over calcula o resultado e avança para a próxima linha;
  • Passo para dentro ou step in: "entra" dentro do código da linha atual, se possível. Ao contrário de step over, step in executa o código da definição de uma chamada de subrotina passo-a-passo. Em outras palavras, acompanha-se a pilha de execução (call stack). Isso permite inspecionar a implementação do código chamado;
  • Passo para fora ou step out: "sai" do código em execução, calculando-se o resultado. Em outras palavras, desempilha-se o topo da call stack, retornando para o código que o chamou. Em última instância, executa-se o programa até o fim.

Os nomes em inglês para step são mais comuns que os nomes em Português.

Para entender melhor como as funcionalidades para execução de código funcionam, pode-se considerar alguns exemplos simples.

Programa Linear

Para depurar um programa usando um depurador, o primeiro passo é iniciar a execução usando o depurador. O processo depende do programa usado. Em IDEs, normalmente existem duas opções: executar o projeto e depurar o projeto (ou executar o projeto usando o depurador). Para usar o depurador, deve-se optar pela segunda opção.

Muitas vezes, o objetivo é iniciar a depuração com o processo com a execução pausada ou em um breakpoint. Alguns IDEs fazem isso por padrão; para o início pausado, outros requerem o uso de um breakpoint.

Para iniciar o depurador:

  • Firefox: o código JavaScript requer uma página HTML como ponto de entrada. Por exemplo:

    <!DOCTYPE html>
    <html lang="pt-BR">
    <head>
      <meta charset="utf-8">
      <title>Depurador em JavaScript</title>
      <meta name="author" content="Franco Eusébio Garcia">
      <meta name="viewport" content="width=device-width, initial-scale=1">
    </head>
    <body>
      <!-- Atualizar com nome do arquivo JavaScript. -->
      <script src="./script.js"></script>
    </body>
    </html>

    Em seguida, deve-se abrir as ferramentas de desenvolvimento (por exemplo, usando F12 no navegador) e acessar a opção Depurador (Debugger). O próximo passo é navegar até o arquivo em Fontes (Sources). Deve-se procurar por file://, abrir o diretório e escolher o arquivo (por exemplo, script.js). Em seguida, deve-se clicar à esquerda do número da primeira linha e adicionar um breakpoint. Isso adicionará um símbolo azul ao lado do número da linha. Para iniciar o depurador, deve-se atualizar a página (F5).

    Alternativamente, pode-se escrever debugger em uma linha própria do código-fonte. Isso será comentado posteriormente com mais detalhes;

  • Thonny: escolha o ícone de um inseto ao lado do botão de play na interface principal. Alternativamente, pressione Ctrl F5. Alternativamente, use Executar (Run), depois Depurar programa atual (Debug current script);

  • ZeroBrane Studio: escolha o ícone de play na interface principal. Alternativamente, pressione F5; Alternativamente, escolha Projeto (Project), depois Iniciar depuração (Start debugging);

  • Godot Engine: é necessário adicionar um breakpoint. Abra o arquivo com o script no editor. Em seguida, clique ao lado esquerdo do número da primeira linha com código após func _ready(). Isso acrescentará um ícone com um quadrado vermelho antes do número da linha. Alternativamente, deixe o cursor na linha e pressione F9.

    Para executar o projeto, escolha o ícone de play ou pressione F5. Alternativamente, pressione F6 para executar o projeto em modo de depuração. Com um breakpoint, ambas as opções funcionam.

// debugger

console.log("Olá, meu nome é Franco!")
console.log("Olá, meu nome é Franco!")
console.log("Olá, meu nome é Franco!")
console.log("Olá, meu nome é Franco!")
console.log("Olá, meu nome é Franco!")
print("Olá, meu nome é Franco!")
print("Olá, meu nome é Franco!")
print("Olá, meu nome é Franco!")
print("Olá, meu nome é Franco!")
print("Olá, meu nome é Franco!")
print("Olá, meu nome é Franco!")
print("Olá, meu nome é Franco!")
print("Olá, meu nome é Franco!")
print("Olá, meu nome é Franco!")
print("Olá, meu nome é Franco!")
extends Node

func _ready():
    print("Olá, meu nome é Franco!")
    print("Olá, meu nome é Franco!")
    print("Olá, meu nome é Franco!")
    print("Olá, meu nome é Franco!")
    print("Olá, meu nome é Franco!")

Com o depurador pausando a execução do processo, pode-se explorar os comandos para controlar sua execução. Na interface de depurador do IDE, deve-se procurar pelas opções de pausar, continuar (ou executar), step into, step in e step out. Use cada uma delas para verificar os resultados. Por conveniência, apresenta-se os atalhos para cada funcionalidade a seguir.

  • Firefox:
    • Executar ou run: deve-se atualizar a página para re-iniciar;
    • Parar ou stop: pode-se fechar a aba ou trocar o endereço do navegador;
    • Pausar, pause ou break: F8;
    • Continuar ou resume: F8;
    • Passo por cima ou step over: F10;
    • Passo para dentro ou step in: F11;
    • Passo para fora ou step out: Shift F11;
  • Thonny:
    • Executar ou run: Ctrl F5;
    • Parar ou stop: Ctrl F2;
    • Pausar, pause ou break: adicionar um breakpoint na linha de interesse;
    • Continuar ou resume: F8;
    • Passo por cima ou step over: F6;
    • Passo para dentro ou step in: F7;
    • Passo para fora ou step out: não possui atalho, mas possui um ícone na interface;
  • ZeroBrane Studio:
    • Executar ou run: F5
    • Parar ou stop: Shift F5
    • Pausar, pause ou break: não possui atalho, mas possui um ícone de pause na interface;
    • Continuar ou resume: F5;
    • Passo por cima ou step over: Shift F10
    • Passo para dentro ou step in: Ctrl Shift F10
    • Passo para fora ou step out: Ctrl F10;
  • Godot Engine:
    • Executar ou run: F6;
    • Parar ou stop: F8;
    • Pausar, pause ou break: F7;
    • Continuar ou resume: F12;
    • Passo por cima, passar por cima, ou step over: F10;
    • Passo para dentro, passar para dentro, ou step in: F11;
    • Passo para fora, passar por fora, ou step out: não possui no momento. Uma forma de contornar a limitação é definir um breakpoint na linha após a chamada da função e continuar a execução.

Para usar os atalhos, a janela ativa deve ser a do IDE.

Caso se use continuar, o programa terminará. Nesse caso, repita as instruções para tentar as outras opções.

Programa com Fluxo Alternativo

Caso se execute o programa linha a linha com step over ou step in, é possível visualizar as linhas executadas e as linhas ignoradas em um programa com estruturas condicionais.

let saudar = true
if (saudar) {
    console.log("Olá, meu nome é Franco!")
} else {
    console.log("Tchau!")
}
saudar = True
if (saudar):
    print("Olá, meu nome é Franco!")
else:
    print("Tchau!")
local saudar = true
if (saudar) then
    print("Olá, meu nome é Franco!")
else
    print("Tchau!")
end
extends Node

func _ready():
    var saudar = true
    if (saudar):
        print("Olá, meu nome é Franco!")
    else:
        print("Tchau!")

Caso se altere saudar para Falso, a próxima execução seguirá o fluxo alternativo.

Uma vantagem de IDEs e ambientes gráficos é a possibilidade de passar o mouse sobre uma variável para inspecionar o valor. Para testar a funcionalidade, pode-se deixar o cursor do mouse sobre o nome saudar no editor de código do IDE ou navegador. O valor Verdadeiro deverá aparecer. O valor apresentado é alterado a cada atribuição da variável.

Programa com Repetições

Caso se execute o programa linha a linha com step over ou step in, é possível visualizar as linhas executadas e as linhas ignoradas a cada passo da repetição. A cada final de iteração, o código retorna à estrutura de repetição para verificar se se deve executar uma nova iteração ou terminar o laço.

let saudar = true
for (let i = 0; i < 5; ++i) {
    if (saudar) {
        console.log("Olá, meu nome é Franco!")
    } else {
        console.log("Tchau!")
    }

    saudar = !saudar
}
saudar = True
for i in range(5):
    if (saudar):
        print("Olá, meu nome é Franco!")
    else:
        print("Tchau!")

    saudar = not saudar
local saudar = true
for i = 1, 5 do
    if (saudar) then
        print("Olá, meu nome é Franco!")
    else
        print("Tchau!")
    end

    saudar = not saudar
end
extends Node

func _ready():
    var saudar = true
    for i in range(5):
        if (saudar):
            print("Olá, meu nome é Franco!")
        else:
            print("Tchau!")

        saudar = not saudar

Quando o laço termina, o depurador avança para a próxima linha com código após o final do bloco. Caso se queira visualizar a linha, pode-se adicionar algo como console.log("Fim") ou print("Fim") como a última linha do código-fonte.

Programa com Subrotinas

Com subrotinas, é interessante usar step in quando a linha atual for escreva_mensagem(saudar). A execução desviará para o código do procedimento escreva_mensagem(_). Dentro do procedimento, pode-se usar step out para retornar ao código que chamou a subrotina, com os resultados calculados. Também pode-se usar step in ou step over para se inspecionar o código do procedimento.

function escreva_mensagem(saudacao) {
    if (saudacao) {
        console.log("Olá, meu nome é Franco!")
    } else {
        console.log("Tchau!")
    }
}

let saudar = true
for (let i = 0; i < 5; ++i) {
    escreva_mensagem(saudar)
    saudar = !saudar
}
def escreva_mensagem(saudacao):
    if (saudar):
        print("Olá, meu nome é Franco!")
    else:
        print("Tchau!")

saudar = True
for i in range(5):
    escreva_mensagem(saudar)
    saudar = not saudar
function escreva_mensagem(saudacao)
    if (saudacao) then
        print("Olá, meu nome é Franco!")
    else
        print("Tchau!")
    end
end

local saudar = true
for i = 1, 5 do
    escreva_mensagem(saudar)
    saudar = not saudar
end
extends Node

func escreva_mensagem(saudacao):
    if (saudacao):
        print("Olá, meu nome é Franco!")
    else:
        print("Tchau!")

func _ready():
    var saudar = true
    for i in range(5):
        escreva_mensagem(saudar)
        saudar = not saudar

Caso uma subrotina chame outra subrotina, pode-se usar step in novamente para acessar o código da nova chamada.

Pontos de Interrupção (Breakpoints)

Um pronto de interrupção (breakpoint) marca uma linha de código na qual se deseja que, caso atingida, a execução do programa pare para inspeção da memória. Isso permite executar o programa automaticamente até atingir-se o ponto de interesse para a depuração. Assim, pode-se economizar tempo para começar a depuração, pois não é necessário executar o código linha a linha até que a execução chegue na linha de código desejada.

Em IDEs, é bastante comum poder habilitar e desabilitar um breakpoint clicando-se ao lado do número da linha, normalmente ao lado esquerdo do editor de texto integrado. Após o clique, um ícone como um círculo ou quadrado aparecerá para representar o breakpoint. Caso se clique novamente sobre o ícone, remove-se ou desativa-se o breakpoint.

Também é possível ativar breakpoints por atalhos do teclado. Para isso, deve-se deixar o cursor do teclado na linha desejada e apertar-se:

  • Firefox: Ctrl B. Também é possível adicionar um breakpoint no código fonte escrevendo debugger (documentação) em uma linha de código;
  • Thonny: o autor não conhece o atalho. Uma alternativa é adicionar breakpoint() (documentação) a uma linha de código, que iniciará o depurador embutido de Python (documentação). Contudo, isso não aciona o depurador visual do IDE;
  • ZeroBrane Studio: Ctrl F9;
  • Godot Engine: F9.

Pontos de Interrupção (Breakpoints) Condicionais

Um recurso avançado presente em alguns depuradores chama-se breakpoint condicional. Pode-se usá-los no Firefox (documentação).

Um breakpoint condicional permite definir uma condição para a ativação do breakpoint. Breakpoints condicionais podem agilizar ainda mais a depuração, pois eles permitem inspecionar o código no momento em que o processo contenha os dados desejados. Por exemplo, pode-se ativar o breakpoint apenas caso uma variável tenha um valor específico (como nome == "Franco") ou pertença a um intervalo de interesse (como (numero > 0) and (numero < 10)).

Para simular um breakpoint condicional em depuradores sem a opção, uma alternativa é definir a condição no código-fonte e adicionar um breakpoint comum dentro dela. Por exemplo:

local nome = "..."
-- ...

if (nome == "Franco") then
    -- Adicionar o breakpoint aqui dentro.
    local adicionar_breakpoint_aqui = 0
    print("Hora de inspecionar o código!")
end

Após corrigir-se o problema, deve-se lembrar de remover o código para a ativação do breakpoint.

Observar Variáveis (Watches)

Depuradores costumam exibir valores para variáveis locais e globais em painéis da interface. Em alguns depuradores, é possível clicar ou navegar nos valores exibidos para inspecionar variáveis complexas, como registros, objetos (de classes), vetores e dicionários.

Além disso, quando se existe interesse em monitorar o valor de uma variável ao longo da execução, pode-se observá-la (watch) para não precisar solicitar o valor (ou passar o cursor do mouse sobre a variável) a todo passo do programa.

O recurso para watch existe em alguns depuradores. A forma de ativá-lo, contudo, costuma variar. Normalmente é necessário procurar um painel chamado Watch ou com termo similar ou traduzido, e adicionar a variável.

  • Firefox: procure por Watch expressions e use o sinal de mais (+) para escrever o nome da variável;
  • Thonny: não fornece o recurso no momento;
  • ZeroBrane Studio: primeiro é necessário ativar a janela, usando o atalho Ctrl Shift W ou clicando-se no ícone da interface principal (uma janela com óculos). Em seguida, pode-se clicar com o botão direito na janela que aparecer e escolher a opção Adicionar observador (Add watch; atalho: Insert). Alternativamente, pode-se clicar com o botão direito no nome de uma variável no código-fonte e escolher a opção Adicionar observador (Add Watch Expression). Também é possível ativar a opção em Exibir (Show), depois escolher Observador (Watch);
  • Godot Engine: não fornece o recurso no momento.

Implementações avançadas não se restringem a variáveis. Por exemplo, elas podem permitir analisar expressões, obter resultados de chamadas de funções, ou acessar valores de memória de referências (ou mesmo endereços arbitrários).

Pilha de Execução (Call Stack)

Outro recurso tradicional em depuradores é um painel para exibir a pilha de execução (ou pilha de chamadas, ou call stack) do programa. A pilha aumenta a cada chamada de subrotina, e diminui a cada retorno.

  • Firefox: presente no painel de Debugger, na seção Call stack;
  • Thonny: deve-se ativar no menu Visualizar (View), opção Pilha (Stack);
  • ZeroBrane Studio: deve-se ativar a janela com o atalho Ctrl Shift S ou usar o ícone da interface principal (três janelas em seqüência com uma flecha entre a segunda a terceira). Também é possível ativar a opção em Exibir (View), depois escolher Pilha de execução (Stack window);
  • Godot Engine: disponível no painel Depurador (Debugger), com o nome Pilha de Quadrados (Stack Frames).

Normalmente é possível clicar duas vezes sobre uma opção da pilha para alterar o contexto. Isso permite, dentre outros, visualizar valores de variáveis em outros níveis da pilha.

É interessante observar a pilha de execução durante a chamada de uma função recursiva.

// debugger

function reverta_cadeia_caracteres(mensagem, indice) {
    if (indice < 0) {
        return ""
    }

    let resultado = mensagem[indice] + reverta_cadeia_caracteres(mensagem, indice - 1)

    return resultado
}

function escreva_de_tras_para_frente(mensagem) {
    let resultado = reverta_cadeia_caracteres(mensagem, mensagem.length - 1)
    console.log(resultado)
}

escreva_de_tras_para_frente("!ocnarF é emon uem ,álO")
def reverta_cadeia_caracteres(mensagem, indice):
    if (indice < 0):
        return ""

    resultado = mensagem[indice] + reverta_cadeia_caracteres(mensagem, indice - 1)

    return resultado

def escreva_de_tras_para_frente(mensagem):
    resultado = reverta_cadeia_caracteres(mensagem, len(mensagem) - 1)
    print(resultado)

escreva_de_tras_para_frente("!ocnarF é emon uem ,álO")
function reverta_cadeia_caracteres(mensagem, indice)
    if (indice < 1) then
        return ""
    end

    local resultado = string.sub(mensagem, indice, indice) .. reverta_cadeia_caracteres(mensagem, indice - 1)

    return resultado
end

function escreva_de_tras_para_frente(mensagem)
    local resultado = reverta_cadeia_caracteres(mensagem, #mensagem)
    print(resultado)
end

escreva_de_tras_para_frente("!ocnarF é emon uem ,álO")
extends Node

func reverta_cadeia_caracteres(mensagem, indice):
    if (indice < 0):
        return ""

    var resultado = mensagem[indice] + reverta_cadeia_caracteres(mensagem, indice - 1)

    return resultado

func escreva_de_tras_para_frente(mensagem):
    var resultado = reverta_cadeia_caracteres(mensagem, len(mensagem) - 1)
    print(resultado)

func _ready():
    escreva_de_tras_para_frente("!ocnarF é emon uem ,álO")

Para observar o crescimento da pilha durante as chamadas recursivas, deve-se usar step in a cada nova chamada. A pilha continuará a crescer até que indice torne-se negativo (ou zero, em Lua). Nesse momento, as funções começarão a retornar e a pilha diminuirá progressivamente, até terminar a inversão de mensagem.

Caso se omita o caso base da recursão, a pilha continuará a crescer até exaurir a memória passível de alocação -- momento em que ocorre um estouro de pilha (stack overflow).

A versão em Lua é particularmente interessante porque não considera valores UTF-8. Como resultado, o á será dividido em dois valores, que são os bytes que formariam o caractere acentuado. O motivo e a forma adequada para iteração foram explicados em Vetores (arrays), cadeias de caracteres (strings), coleções (collections) e estruturas de dados. Para manter o exemplo similar ao das outras linguagens, a implementação itera nos bytes da cadeia de caracteres (ao invés de um code point UTF-8).

Estratégias e Técnicas para Teste e Depuração

Conforme adquire-se experiência em programação, desenvolve-se estratégias e técnicas para testar e depurar programas. Para testes, elas servem tanto para a criação de testes (como testes unitários), quanto para testar o código após a escrita. Técnicas para depuração, por outro lado, ajudam a identificar um bug e corrigi-lo.

As próximas seções apresentam estratégias e técnicas úteis para teste e depuração, assim como alguns termos técnicos comumente usados. Doravante, a expressão garantia de qualidade, mais conhecido pela sigla QA do termo inglês quality assurance, deve ser parte de seu vocabulário e práticas de programação.

Intervalos de Valores

Uma forma de garantir que uma solução esteja correta consiste em realizar testes exaustivos, isto é, testar todos os possíveis valores de entrada para um sistema (ou subrotina) e verificar se os resultados fornecidos são corretos.

Evidentemente, isso tende a ser inviável. Uma heurística útil consiste em identificar intervalos de valores nos quais o resultado (ou a forma de calculá-lo) possam variar. Por exemplo, em um código que processe um vetor com 10 elementos, é interessante testar:

  • Um valor em um índice intermediário, como 7;
  • O valor no primeiro índice (0 ou 1, dependendo da linguagem);
  • O valor no último índice (9 ou 10, dependendo da linguagem);
  • O índice antes do primeiro (-1 ou 0), para garantir que ele não seja processado. Em linguagens nas quais o índice -1 acessa o último valor do vetor, deve-se ajustar o teste de acordo;
  • O índice após o último (10 ou 11), para garantir que ele não seja processado.

O motivo das escolhas é que uma implementação típica de algoritmo para processamento de vetores utiliza uma estrutura de repetições. Erros de índice por um são comuns em programação, a ponto de ter um nome especial: off-by-one error. A criação de casos de teste para os índices anteriores fornece uma heurística de que todos os valores do vetor foram processados adequadamente em um laço.

Ainda melhor é considerar o tamanho do vetor variável e definir um teste exaustivo baseado em um vetor com poucos elementos. Para um exemplo menor, poder-se-ia considerar um vetor com 3 posições. Isso facilitaria um teste exaustivo para todos os possíveis índices e resultados esperados. Assumindo-se que o laço use o tamanho do vetor, a implementação também deve funcionar corretamente para tamanhos maiores.

A escolha de valores máximos e mínimos de um intervalo é uma técnica útil em teste de software. Em particular, esses valores extremos possuem um nome especial: edge case, que significa algo como caso extremo ou de borda.

Casos Especiais

Diversos algoritmos possuem valores cujos processamento é igual ou similar. Esse processamento é genérico e válido para uma classes de valores, gerando algo conhecido como happy path ("caminho feliz") para execução.

Por exemplo, no caso da divisão, o cálculo funciona corretamente para todos os valores reais, exceto zero. A divisão por zero possui dois problemas:

  1. Denominador zero para numeradores diferentes de zero. No caso de números reais, ela pode resultar em infinito (como uma aproximação) em aritmética ponto flutuante. No caso de números inteiros, deve-se evitá-la;
  2. Divisão de zero por zero, que é uma indeterminação matemática.

Casos especiais requerem tratamento e casos de testes específicos. Logo, é importante antecipá-los e tratá-los adequadamente na implementação.

Em particular, o autor prefere tratar casos especiais o quanto antes, usando a técnica de early return, introduzida em Subrotinas (Funções e Procedimentos). Essa escolha permite eliminar casos especiais para se forcar no happy path.

Casos Patológicos (Corner Cases)

Um caso especial que exige condições específicas para ocorrer é chamado de caso patológico ou corner case. Diferentemente de edge cases, corner cases podem ser difíceis de antecipar, pois podem ocorrer em situações tão particulares que sejam desconhecidas no momento do design de uma solução. Por exemplo, eles podem depender de uma configuração específica de hardware, de uma seqüência rara de interações que possam gerar o estado de computação propício para a ocorrência, ou mesmo de condições externas ao programa.

Infelizmente, pode ser difícil reproduzir um corner case; conseqüentemente, pode ser igualmente difícil depurá-lo e corrigi-lo. De qualquer forma, assim que identificado (ou notificado por alguém), convém criar um teste especial para abordá-lo. No pior cenário, ele deve ser um problema conhecido do projeto.

Isolamento de Região

Quando se depura um código com bugs, um dos primeiros passos é tentar identificar a região (área do código) em que o(s) problema(s) ocorre(m). O objetivo é reduzir a região cada vez mais, até chegar-se à origem do problema.

Para isso, convém considerar escopos e subrotinas. Escopos locais e subrotinas reduzem a região por restringirem o acesso e a modificação de valores em memória, assim como delimitam instruções. Quando se trabalha com escopo local e subrotinas, reduz-se os locais para procurar pelo problema. Em geral, o problema:

  1. Estará nos valores passados na chamada da subrotina. Em outras palavras, passou-se um valor errado como parâmetro. Isso pode ocorrer por erro de digitação, uso de variável incorreta, ou do uso de uma variável com valor incorreto (calculado anteriormente);
  2. Estará no valor retornado pela subrotina. Isso significa que passou-se os valores corretos para a subrotina. O problema provavelmente estará, pois, na implementação da subrotina.

A análise da pilha de execução pode ser particularmente útil, por revelar as chamadas de subrotinas realizadas, como um histórico de operações recentes e potencialmente relacionadas. Quando se identifica pontos de interesse, pode-se marcá-los com breakpoints para executar automaticamente o projeto com o depurador até atingi-los.

Por outro lado, o escopo global pode ampliar o espaço para todo o programa. Em potencial, toda parte do projeto que manipule a variável global pode ser a causa de um problema. É ainda pior quando o projeto utiliza threads ou código paralelo, porque o estado para gerar o bug pode precisar de interações ou tempos específicos para acontecer.

Portanto, esse é mais um motivo para preferir escopo local e modularização.

Exemplo Mínimo Reprodutível

Uma forma particularmente útil para resolver bugs complexos consiste em criar um exemplo mínimo que implemente e demonstre o problema -- e nada mais. Isso é chamado de Exemplo Mínimo Reprodutível, Minimal, Reproducible Example ou de minimal working example.

O princípio é reduzir o problema à menor e mais simples implementação que possa demonstrá-lo e reproduzi-lo de forma determinística. Isso é útil, pois, dentre outros:

  1. Permite focar no problema em questão, sem interferência de outras partes da implementação que não sejam de interesse;
  2. Reduz o tempo necessário para modificar a implementação, executar o projeto e verificar o resultado da alteração;
  3. Fornece um exemplo menor que pode ser mostrado para outras pessoas. Isso é particularmente útil, por exemplo, caso se solicite ajuda a amigas e amigos, colegas, ou em fóruns de Internet, como Stack Overflow. Por sinal, o website Stack Overflow fornece diretivas de como criar um exemplo mínimo reprodutível.

Quando se solicita ajuda a outras pessoas, a criação de um exemplo mínimo reprodutível é útil para a outra pessoa, por reduzir o tempo necessário para que ela possa entender o problema. Ela também é útil para a pessoa que pede ajuda, pois ela não precisará compartilhar o projeto inteiro. Isso, dentre outros, pode evitar o compartilhamento de código ou dados privados, reduz o tamanho do código compartilhado, e minimiza dependências externas.

Novos Itens para Seu Inventário

Ferramentas:

  • Depurador.

Habilidades:

  • Depuração de projetos;
  • Teste de software;
  • Tratamento de erros.

Conceitos:

  • Patch;
  • Programação defensiva;
  • Tratamento de erros;
  • Código de erro;
  • Exceções;
  • Overhead;
  • Traceback;
  • Logging;
  • Análise de cobertura;
  • Depuração;
  • Depurador;
  • Step over;
  • Step in;
  • Step out;
  • Breakpoint;
  • Breakpoint condicional;
  • Watch;
  • Pilha de execução;
  • Testes exaustivos;
  • Intervalos de valores;
  • Happy path;
  • Edge cases;
  • Corner cases;
  • Exemplo mínimo reprodutível.

Recursos de programação:

  • Exceções;
  • Tratamento de erros;
  • Funções variádicas.

Pratique

  1. Crie testes unitários para exercícios ou projetos que você criou em tópicos anteriores;
  2. Use um depurador para analisar um projeto criado por outra pessoa. Para isso, você pode procurar por projetos de código aberto na linguagem de programação de sua preferência;
  3. Pense em estratégias e técnicas para teste e depuração que você usou ao longo de suas atividades de programação. Como você pode melhorá-las? Em que situações você pode aplicá-las? Como introduzir depuradores e outras recursos apresentados neste tópico para melhorar seus processos de teste e depuração?

Próximos Passos

Testes e depuração são atividades que fazem parte de todo processo de software. As práticas podem ser informais ou formais; contudo, sempre presentes. Afinal, um dos primeiros passos após a escrita de um Olá, mundo! foi executar o programa para verificar se a mensagem, de fato, apareceria na tela.

O desenvolvimento de software é complexo. É bastante comum que um projeto não compile ou funcione corretamente imediatamente após escrito. De fato, pessoas experientes em programação tendem a desconfiar da própria solução que funcionou corretamente no primeiro uso. É possível que exista um edge case ou corner case não antecipado.

De qualquer forma, resta um tópico ainda não explorado para conceitos básicos: memória e ponteiros. A intenção do autor é explorá-lo no futuro, possivelmente em uma série de programação usando a linguagem C e a linguagem C++.

Como antecipado em Operações Bit-a-Bit e alguns tópicos anteriores, o plano atual é iniciar uma nova série focada em simulações. É hora de colocar suas habilidades de programação em prática em projetos mais dinâmicos e interativos.

  1. Introdução;
  2. Ponto de entrada e estrutura de programa;
  3. Saída (para console ou terminal);
  4. Tipos de dados;
  5. Variáveis e constantes;
  6. Entrada (para console ou terminal);
  7. Aritmética e Matemática básica;
  8. Operações relacionais e comparações;
  9. Operações lógicas e Álgebra Booleana;
  10. Estruturas de condição (ou condicionais ou de seleção);
  11. Subrotinas: funções e procedimentos;
  12. Estruturas de repetição (ou laços ou loops);
  13. Vetores (arrays), cadeias de caracteres (strings), coleções (collections) e estruturas de dados;
  14. Registros (structs ou records);
  15. Arquivos e serialização (serialization ou marshalling);
  16. Bibliotecas;
  17. Entrada em linha de comando;
  18. Operações bit-a-bit (bitwise operations);
  19. Testes e depuração.
  • Informática
  • Programação
  • Iniciante
  • Pensamento Computacional
  • Aprenda a Programar
  • Python
  • Lua
  • Javascript
  • Godot
  • Gdscript