Pruebas unitarias: primeros tests y diffs de fallos

Tu primer archivo unittest en Python: casos borde en los límites, diffs de fallos reales, doctest, Dart test sin red y el costo medido de 10,000 tests.

Serie: curso de pruebas y depuración 3/6 (intermedio). Para quien ya sabe leer un stack trace y usar un depurador (lecciones 1 y 2), pero nunca escribió un archivo de pruebas propio. Al terminar vas a poder escribir un archivo unittest desde cero, elegir casos mirando los límites, leer un diff de fallo sin adivinar y decidir qué no vale la pena probar.

Si saliste de un bootcamp, conseguiste tu primer trabajo y tu primer pull request volvió con un solo comentario, "¿y los tests?", esta lección es para ti. También es para quien trabaja como freelance y tuvo que explicarle a un cliente por qué un cálculo de cobro salió mal en producción. Todo lo de abajo se ejecutó con Python 3.9.6 (parte del comportamiento también se revisó en 3.12.14) y Dart 3.11 en una Mac con Apple Silicon (M1 Pro); las rutas en las salidas se acortaron a ~/demo/.

Lo principal, todo medido más abajo: un archivo de pruebas con un error en el nombre ejecutó cero tests y aun así imprimió OK en Python 3.9; pensar en los límites encontró cuatro entradas que los tests del camino feliz nunca tocaban; y una suite de 10,000 tests pequeños tardó 286 ms, así que la velocidad nunca es excusa para no escribir tests pequeños.

Tu primer archivo de pruebas en cinco minutos

Primero, tres términos. Una prueba unitaria (unit test) llama a una pieza pequeña de código (normalmente una función) con entradas conocidas y revisa la salida. Una aserción es esa revisión: "este valor debe ser igual a este otro". Un test runner encuentra tus pruebas, las ejecuta y reporta. Python trae uno en la biblioteca estándar, unittest, así que no hay nada que instalar.

Este es el código que vamos a probar: divide entre varias personas la cuenta de la pizza del bootcamp. El dinero se guarda en centavos enteros, porque un float no puede representar 0.1 de forma exacta.

# bill.py
"""Dividir la cuenta de la pizza del bootcamp. Montos en centavos (enteros)."""

FREE_SHIPPING_FROM = 99900   # $999.00
SHIPPING_FEE = 9900   # $99.00


def split_bill(total_cents, people):
    """Divide total_cents en partes enteras que suman exactamente el total.

    >>> split_bill(1000, 3)
    [334, 333, 333]
    >>> split_bill(900, 3)
    [300, 300, 300]
    >>> split_bill(10.0, 3)
    Traceback (most recent call last):
      ...
    TypeError: total_cents must be an int, got float
    """
    if not isinstance(total_cents, int):
        raise TypeError(f"total_cents must be an int, got {type(total_cents).__name__}")
    if people < 1:
        raise ValueError(f"people must be >= 1, got {people}")
    base, extra = divmod(total_cents, people)
    return [base + 1] * extra + [base] * (people - extra)


def shipping_fee(subtotal_cents):
    return 0 if subtotal_cents >= FREE_SHIPPING_FROM else SHIPPING_FEE


def format_share(name, cents):
    return f"{name} paga ${cents // 100}.{cents % 100:02d}"

Y el archivo de pruebas al lado:

# test_bill.py
import doctest
import unittest

import bill
from bill import format_share, shipping_fee, split_bill


class SplitBillTest(unittest.TestCase):
    def test_even_total_gives_equal_shares(self):
        # Arrange (preparar)
        total, people = 90000, 3
        # Act (actuar)
        shares = split_bill(total, people)
        # Assert (verificar)
        self.assertEqual(shares, [30000, 30000, 30000])

    def test_remainder_goes_to_first_people(self):
        self.assertEqual(split_bill(100000, 3), [33334, 33333, 33333])

    def test_shares_always_add_up_to_total(self):
        for total in (0, 1, 2, 99, 100000, 100001):
            with self.subTest(total=total):
                self.assertEqual(sum(split_bill(total, 3)), total)

    def test_zero_people_is_rejected(self):
        with self.assertRaises(ValueError):
            split_bill(1000, 0)

    def test_float_total_is_rejected_clearly(self):
        with self.assertRaisesRegex(TypeError, "total_cents must be an int"):
            split_bill(10.0, 3)

    def test_negative_total_still_adds_up(self):
        self.assertEqual(split_bill(-100, 3), [-33, -33, -34])


class ShippingFeeTest(unittest.TestCase):
    def test_one_centavo_below_threshold_pays_fee(self):
        self.assertEqual(shipping_fee(99899), 9900)

    def test_exactly_threshold_is_free(self):
        self.assertEqual(shipping_fee(99900), 0)


class FormatShareTest(unittest.TestCase):
    def test_pads_centavos(self):
        self.assertEqual(format_share("Lucía", 3305), "Lucía paga $33.05")


def load_tests(loader, tests, ignore):
    tests.addTests(doctest.DocTestSuite(bill))
    return tests


if __name__ == "__main__":
    unittest.main()

Ejecútalo desde la carpeta que tiene ambos archivos:

$ python3 -m unittest -v
test_pads_centavos (test_bill.FormatShareTest) ... ok
test_exactly_threshold_is_free (test_bill.ShippingFeeTest) ... ok
test_one_centavo_below_threshold_pays_fee (test_bill.ShippingFeeTest) ... ok
test_even_total_gives_equal_shares (test_bill.SplitBillTest) ... ok
test_float_total_is_rejected_clearly (test_bill.SplitBillTest) ... ok
test_negative_total_still_adds_up (test_bill.SplitBillTest) ... ok
test_remainder_goes_to_first_people (test_bill.SplitBillTest) ... ok
test_shares_always_add_up_to_total (test_bill.SplitBillTest) ... ok
test_zero_people_is_rejected (test_bill.SplitBillTest) ... ok
split_bill (bill)
Doctest: bill.split_bill ... ok

----------------------------------------------------------------------
Ran 10 tests in 0.002s

OK

Diez tests: nueve métodos más el doctest que agrega load_tests (lo vemos más abajo). Fíjate en el orden: las clases y los métodos corren en orden alfabético, no en el orden del archivo. Nunca escribas un test que dependa de que otro haya corrido antes.

Diagrama de flujo. python3 -m unittest descubre archivos llamados test*.py y métodos que empiezan con test. Cada test prepara, actúa y verifica. Si la aserción se cumple, el resultado es un éxito, mostrado como punto. Si una aserción es falsa, el resultado es FAIL, mostrado como F. Si se escapa una excepción inesperada antes de que la aserción decida, el resultado es ERROR, mostrado como E. Un archivo o método mal nombrado nunca se descubre, así que corren cero tests.
Lo que hace una corrida de unittest. El descubrimiento solo encuentra archivos test*.py y métodos test_*; cada test prepara, actúa y verifica, y termina como éxito (.), fallo (F) o error (E).

La trampa del nombre: "Ran 0 tests" y OK

python3 -m unittest sin argumentos usa descubrimiento automático (discovery): importa los archivos que coinciden con test*.py y ejecuta los métodos cuyo nombre empieza con test. Si nombras el archivo bill_tests.py y el método check_remainder, obtienes esto, aunque la aserción de adentro esté mal a propósito:

$ python3 -m unittest          # Python 3.9.6
----------------------------------------------------------------------
Ran 0 tests in 0.000s

OK
$ echo $?
0
$ python3.12 -m unittest       # Python 3.12.14, mismos archivos
----------------------------------------------------------------------
Ran 0 tests in 0.000s

NO TESTS RAN
$ echo $?
5

Cambiar solo el nombre del archivo a test_bill.py siguió dando 0 tests, porque el nombre del método también estaba mal. En 3.9, un pipeline de CI se quedaría en verde para siempre. Costumbre sana: cada vez que agregas un test, hazlo fallar una vez a propósito y confirma que de verdad falla.

Arrange, act, assert, y nombres que se leen como frases

test_even_total_gives_equal_shares muestra la forma en tres partes que tienen casi todos los buenos tests:

  • Arrange (preparar): arma las entradas (y, en la próxima lección, los colaboradores falsos).
  • Act (actuar): llama una sola vez a lo que estás probando.
  • Assert (verificar): revisa el resultado.

No vas a escribir los comentarios siempre, pero si un test tiene dos pasos de "act", casi siempre son dos tests. El nombre debe decir qué, bajo qué condición y qué se espera: test_one_centavo_below_threshold_pays_fee te dice cuál es el bug antes de abrir el archivo. test_shipping_2 no te dice nada cuando el cliente te escribe un domingo.

subTest es la herramienta para "la misma verificación con muchas entradas". Sin él, el ciclo se detiene en el primer valor que falla; con él, cada valor se reporta por separado, como vas a ver en la salida de fallos.

Encontrar casos borde pensando en los límites

Los primeros tests que uno escribe siguen el camino feliz: $900 entre tres personas. Los bugs viven en los bordes. Antes de escribir más tests, probé la primera versión de split_bill (la que no tenía el chequeo con isinstance) con entradas elegidas en los límites: una persona, menos centavos que personas, cero, negativo y tipos equivocados.

split_bill(1000, 1)        -> [1000]
split_bill(2, 3)           -> [1, 1, 0]
split_bill(0, 3)           -> [0, 0, 0]
split_bill(-100, 3)        -> [-33, -33, -34]
split_bill(1000, True)     -> [1000]
split_bill(1000, 2.5)      -> TypeError: can't multiply sequence by non-int of type 'float'
split_bill(1000.0, 3)      -> TypeError: can't multiply sequence by non-int of type 'float'
split_bill(1000, '3')      -> TypeError: '<' not supported between instances of 'str' and 'int'
split_bill(1000, 0)        -> ValueError: people must be >= 1, got 0

Cuatro hallazgos que los tests del camino feliz nunca habrían mostrado:

Entrada Qué pasó Decisión
(2, 3) [1, 1, 0]: alguien no paga nada correcto; el total cuadra
(-100, 3) un reembolso, y la suma sigue siendo -100 se queda; lo fijamos con un test
(1000.0, 3) un TypeError sobre "multiply sequence" que apunta al problema equivocado rechazar floats con un mensaje claro
(1000, True) True cuenta como 1 persona, porque bool es subclase de int aceptado; documentado, sin test

El caso del float es el realista: alguien pasa pesos desde un formulario (10.0) en lugar de centavos. El mensaje de error habla de secuencias, así que quien depura empieza a buscar en el lugar equivocado. Por eso split_bill ahora revisa el tipo, y por eso test_float_total_is_rejected_clearly revisa el mensaje y no solo la clase de la excepción.

Una lista rápida de límites: cero, uno, muchos; justo debajo, exactamente en y justo arriba de un umbral (99899, 99900); entrada vacía; negativo; el tipo equivocado; valores muy grandes. Los tests de envío usan justo el par 99899/99900, porque >= contra > es el bug de un carácter más común en código de precios.

Cómo leer un test que falla

Ahora rompe el código de tres maneras pequeñas, del tipo que se le escapa a un revisor cansado: reemplaza la lógica de divmod por [total_cents // people] * people, cambia >= por > en shipping_fee y quita el relleno :02d. La salida real, recortada:

$ python3 -m unittest
FF...FF.F
======================================================================
FAIL: test_pads_centavos (test_bill.FormatShareTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "~/demo/bill/test_bill.py", line 47, in test_pads_centavos
    self.assertEqual(format_share("Lucía", 3305), "Lucía paga $33.05")
AssertionError: 'Lucía paga $33.5' != 'Lucía paga $33.05'
- Lucía paga $33.5
+ Lucía paga $33.05
?                +

======================================================================
FAIL: test_exactly_threshold_is_free (test_bill.ShippingFeeTest)
...
AssertionError: 9900 != 0
======================================================================
FAIL: test_negative_total_still_adds_up (test_bill.SplitBillTest)
...
AssertionError: Lists differ: [-34, -34, -34] != [-33, -33, -34]

First differing element 0:
-34
-33

- [-34, -34, -34]
?    ^    ^

+ [-33, -33, -34]
?    ^    ^

======================================================================
FAIL: test_shares_always_add_up_to_total (test_bill.SplitBillTest) (total=1)
...
AssertionError: 0 != 1
...
----------------------------------------------------------------------
Ran 10 tests in 0.002s

FAILED (failures=9)

Cómo leerla, de arriba abajo:

  1. La línea de progreso FF...FF.F tiene un carácter por test: . pasó, F falló, E error. Son 9 caracteres para 10 tests y aun así el resumen dice 9 fallos: los fallos de subTest se cuentan uno por cada valor que falla (total=1, 2, 100000 y 100001), pero comparten un solo carácter.
  2. El traceback termina en tu línea de test, no dentro de unittest. La línea 47 de test_bill.py es donde hay que mirar.
  3. first != second: unittest llama a sus dos argumentos first y second, no "real" y "esperado". Como siempre escribimos assertEqual(real, esperado), la línea con - es lo que devolvió el código y la línea con + es lo que queríamos. Mantén ese orden en todos tus tests, o el diff te va a mentir.
  4. La línea con ? apunta a los caracteres exactos que cambian: un + debajo del 0 que falta, dos ^ debajo de los dígitos cambiados. Los acentos como la "í" de Lucía ocupan una sola columna, así que aquí el marcador cae en el lugar correcto.
  5. Las listas muestran "First differing element 0" antes del diff. Aquí el caso negativo revela un segundo bug que a cualquier cliente le importaría: la división entera hacia abajo convierte un reembolso de 100 centavos en 102.
  6. (total=1) después del nombre del test es la etiqueta de subTest. Sin subTest solo verías el primer total que falla.

El código de salida fue 1, que es lo que revisa el CI.

FAIL contra ERROR

Quita la guarda people < 1 y el chequeo de tipo, y aparecen dos letras distintas:

....F...EF
======================================================================
ERROR: test_zero_people_is_rejected (test_bill.SplitBillTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "~/demo/bill/test_bill.py", line 27, in test_zero_people_is_rejected
    split_bill(1000, 0)
  File "~/demo/bill/bill.py", line 19, in split_bill
    base, extra = divmod(total_cents, people)
ZeroDivisionError: integer division or modulo by zero

======================================================================
FAIL: test_float_total_is_rejected_clearly (test_bill.SplitBillTest)
----------------------------------------------------------------------
TypeError: can't multiply sequence by non-int of type 'float'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "~/demo/bill/test_bill.py", line 31, in test_float_total_is_rejected_clearly
    split_bill(10.0, 3)
AssertionError: "total_cents must be an int" does not match "can't multiply sequence by non-int of type 'float'"

FAIL significa que una aserción se evaluó y resultó falsa. ERROR significa que el test se cayó antes de poder decidir: assertRaises(ValueError) solo atrapa ValueError, así que el ZeroDivisionError se escapó. El segundo bloque es una excepción encadenada (lección 1): primero aparece el TypeError original y luego la aserción que rechazó su mensaje.

doctest: ejemplos que no se quedan viejos

Las líneas >>> del docstring de split_bill son un doctest: Python las ejecuta y compara lo que imprime con la línea de abajo. Ejecútalas solas con python3 -m doctest bill.py (no imprime nada si todo pasa y sale con código 0) o agrega -v. La función load_tests del archivo de pruebas las suma a la corrida normal de unittest.

Cuando cambié la lista esperada del docstring a [333, 333, 334]:

**********************************************************************
File "~/demo/bill/bill.py", line 10, in bill.split_bill
Failed example:
    split_bill(1000, 3)
Expected:
    [333, 333, 334]
Got:
    [334, 333, 333]
**********************************************************************
1 items had failures:
   1 of   3 in bill.split_bill
***Test Failed*** 1 failures.

Usa doctest para ejemplos cortos que además sirven de documentación. Evítalo para cualquier cosa cuya forma impresa sea inestable: el orden de un dict que viene de datos externos, floats con colas largas, direcciones de objetos como <Foo at 0x10...>. Eso va en unittest.

Una trampa con la que me topé al preparar esto: edité el docstring dos veces en menos de un segundo, y la segunda edición dejó el archivo con el mismo tamaño. Python reutilizó el bytecode en caché (.pyc), porque la fecha de modificación y el tamaño registrados coincidían, así que el docstring viejo siguió fallando después de la corrección. Borrar __pycache__ no sirvió, porque este Python guarda los .pyc bajo un prefijo de caché aparte. Hacer touch al archivo un segundo después lo arregló. A mano casi nunca te va a pasar, pero a los scripts que reescriben código fuente sí.

El mismo test en Dart, sin red

¿Se puede correr package:test de Dart sin acceso a la red? La condición era que dart pub get --offline resolviera todo desde la caché local de paquetes. Lo hizo en 1.0 s y resolvió test 1.30.0:

// lib/pricing.dart
int shippingFee(int subtotal) {
  if (subtotal < 0) throw ArgumentError.value(subtotal, 'subtotal');
  return subtotal >= 5000 ? 0 : 500;
}

// test/pricing_test.dart
import 'package:pricing/pricing.dart';
import 'package:test/test.dart';

void main() {
  group('shippingFee', () {
    test('charges 500 just below the threshold', () {
      expect(shippingFee(4999), 500);
    });
    test('is free exactly at the threshold', () {
      expect(shippingFee(5000), 0);
    });
    test('rejects a negative subtotal', () {
      expect(() => shippingFee(-1), throwsArgumentError);
    });
  });
}

Con >= cambiado por >, dart test reportó (código de salida 1):

00:00 +1 -1: test/pricing_test.dart: shippingFee is free exactly at the threshold [E]
  Expected: <0>
    Actual: <500>

  package:matcher              expect
  test/pricing_test.dart 10:7  main.<fn>.<fn>

00:00 +2 -1: Some tests failed.

El matcher de Dart etiqueta los lados como Expected y Actual, así que el orden de los argumentos importa menos que en unittest.

Si package:test no está disponible sin red, la alternativa es un assert simple, pero revisa antes cómo lo trata tu entorno. En esta prueba, los tres casos siguientes llegaron hasta la última línea sin ningún error:

Entorno El assert simple se ignora cuando Visto aquí
Dart dart run file.dart (los asserts están apagados sin --enable-asserts) sí, se salta en silencio
Python python3 -O file.py (se eliminan las sentencias assert) sí, se salta en silencio
PHP 8.5 php -d zend.assertions=-1 (la configuración típica de producción) sí, se salta en silencio

Dentro de dart test los asserts sí están activos: un test que esperaba AssertionError pasó. PHPUnit no está instalado en esta máquina, así que en PHP solo se ejecutó assert con zend.assertions=1.

¿Cuánto tarda una suite a medida que crece?

Una excusa común es "los tests son lentos". Generé archivos con N tests triviales (cada uno llama una vez a split_bill) y medí python3 -m unittest como proceso completo: 6 corridas por tamaño, descartando la primera (en frío) y tomando la mediana de las otras 5. Python 3.9.6, Mac con Apple Silicon (M1 Pro).

Tests (N) Tiempo total del proceso "Ran N tests in"
1 48.4 ms 0.0 ms
10 46.1 ms 0.0 ms
100 52.7 ms 1.0 ms
1,000 69.8 ms 19.0 ms
10,000 285.9 ms 192.0 ms
Gráfico de barras. 1 test tardó 48.4 ms, 10 tests 46.1 ms, 100 tests 52.7 ms, 1,000 tests 69.8 ms y 10,000 tests 285.9 ms.
Tiempo total de python3 -m unittest para N tests pequeños: mediana de 5 corridas tras una en frío, Python 3.9.6, Mac con Apple Silicon (M1 Pro). Hasta 100 tests pagas sobre todo el arranque del intérprete; después, unos 20 microsegundos por test.

Hasta 100 tests estás midiendo el arranque de Python (unos 45 ms). Después, cada test extra cuesta unos 19 a 24 microsegundos. Con -v, la corrida de 10,000 tests tardó 432 ms en vez de 272 ms (una medición aparte): imprimir una línea por test cuesta más que ejecutarlo.

El mismo experimento con dart test (3 corridas por tamaño después de un calentamiento): de 0.91 a 0.95 s para 1 test, de 1.07 a 1.24 s para 100, de 1.84 a 1.94 s para 1,000 y de 6.3 a 6.4 s para 10,000. El costo fijo de Dart es mayor (compila y arranca un isolate), y su reporter hace más trabajo por test.

La conclusión: los tests unitarios pequeños son baratos. Lo que vuelve lenta una suite son unos pocos tests que duermen, tocan la red o una base de datos real. La lección 4 muestra cómo reemplazarlos.

Qué NO probar

Los tests son código que tienes que mantener. Estos casi siempre cuestan más de lo que devuelven:

  • El lenguaje y la biblioteca estándar. Un test de que divmod(7, 2) == (3, 1) prueba Python, no tu código.
  • Funciones auxiliares privadas, directamente. Pruébalas a través de la función pública. Así puedes refactorizar la auxiliar sin reescribir tests.
  • Textos exactos que no son tuyos. Comparar contra el mensaje de Python "can't multiply sequence…" se rompería en la siguiente versión de Python. Nuestro test compara contra el mensaje que nosotros escribimos.
  • Código trivial sin lógica, como una propiedad que devuelve un campo.
  • La implementación en lugar del resultado. "split_bill llama a divmod una vez" se rompe con refactorizaciones inofensivas. "Las partes suman el total" sobrevive a ellas.
  • Salidas aleatorias o que dependen de la hora, tal cual. Fija primero la semilla o el reloj (lección 5).

Lo que siempre conviene probar: cada bug que corriges (primero el test que falla, luego la corrección), los límites de cada regla que tenga un número y el manejo de errores del que dependen quienes llaman a tu código.

Límites honestos y notas de versión

  • Python 3.9.6 fue el entorno principal; Python 3.12.14 se usó solo para comparar mensajes. El código de salida 5 de python -m unittest cuando no corre ningún test y la opción --durations son nuevos en 3.12; en 3.9, --durations falló con unrecognized arguments. La primera corrida con --durations en 3.12 mostró 0.335 s para el doctest (arranque en frío); las siguientes mostraron entre 0.001 y 0.002 s.
  • assertRaisesRegex y subTest existen desde Python 3.2 y 3.4.
  • Dart 3.11.5 con test 1.30.0 desde la caché local de pub; no se descargó nada. Otras instalaciones de Dart pueden resolver otra versión.
  • Los tiempos son de una sola Mac con Apple Silicon (M1 Pro). Los números absolutos van a cambiar en tu máquina; la forma (un costo fijo de arranque y luego unos microsegundos por test pequeño) no debería.
  • pytest es el runner de terceros más popular y tiene diffs más amables, pero no está instalado aquí y no se ejecutó.

Ejercicios

  1. Agrega un test de que split_bill(5, 5) devuelve cinco partes de 1, y otro de que split_bill(4, 5) devuelve [1, 1, 1, 1, 0]. Antes de correrlos, predice si pasan.
  2. Escribe un test que falle si alguien cambia >= por > en shipping_fee, y explica por qué shipping_fee(99899) por sí solo no puede atrapar ese bug.

Respuestas

  1. Los dos pasan. divmod(5, 5) es (1, 0), así que no sobra ningún centavo; divmod(4, 5) es (0, 4), así que las primeras cuatro personas reciben 0 + 1 y la última 0. Si tu predicción falló, anota por qué: ese es el límite en el que no habías pensado.
  2. self.assertEqual(shipping_fee(99900), 0). Con 99899, tanto >= como > dan la misma respuesta (9900), así que el test no puede distinguirlos. Solo el valor exactamente en el umbral separa los dos operadores.

Para seguir

Preguntas frecuentes

¿Por qué unittest dice 'Ran 0 tests' y aun así OK?

El descubrimiento automático solo toma archivos llamados test*.py y métodos que empiezan con test. Un archivo bill_tests.py con un método check_remainder ejecutó cero tests, y Python 3.9 imprimió OK con código de salida 0. Python 3.12 imprime NO TESTS RAN y sale con código 5, que el CI toma como fallo.

En el diff de un fallo de unittest, ¿cuál lado es el valor esperado?

unittest no los nombra: los llama first y second. Si siempre escribes assertEqual(real, esperado), las líneas con - son lo que devolvió tu código y las líneas con + son lo que esperabas. La línea con ? marca exactamente los caracteres que difieren.

¿Cuánto tarda una suite de unittest a medida que crece?

En una Mac con Apple Silicon (M1 Pro) y Python 3.9, entre 46 y 53 ms para 1 a 100 tests pequeños (casi todo es el arranque del intérprete), 70 ms para 1,000 y 286 ms para 10,000: unos 20 microsegundos por test extra. Un solo test lento que toca la red cuesta más que miles de tests rápidos.

  • #testing
  • #intermedio
  • #python
  • #unittest
  • #doctest
  • #dart

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

Acerca de · Apps

↑