Un proyecto completo en C#: CSV, LINQ, JSON y pruebas

Hasta aquí, cada lección se ejecutaba en un solo archivo con dotnet run. Los programas de verdad tienen varias clases repartidas en archivos, dependencias, pruebas automáticas y una forma de entregarse a quien los va a usar. En esta última lección montamos uno completo y pequeño: una herramienta que lee tus gastos de un CSV, los resume por categoría con LINQ, guarda el resultado en JSON y tiene sus pruebas con xUnit. Usa casi todo lo del curso: records de la lección 5, LINQ de la lección 7 y excepciones de la lección 4.

Qué vamos a construir

Una herramienta de línea de órdenes, Gastos, que recibe un archivo como este:

fecha,categoria,importe
2026-09-28,Alimentación,54.20
2026-10-01,Transporte,12.50
2026-10-02,Alimentación,23.75
2026-10-05,Ocio,40.00
2026-10-09,Transporte,12.50
2026-10-12,Alimentación,61.10
2026-10-15,Hogar,120.00
2026-10-20,Ocio,18.90

y escribe un resumen por categoría, de la que más gasta a la que menos, y lo guarda también en resumen.json para que lo use otro programa. Junto a ella habrá un segundo proyecto con las pruebas. Este es el resultado final, para que sepas adónde vamos:

salidaAlimentación    3    139.05
Hogar           1    120.00
Ocio            2     58.90
Transporte      2     25.00
Total           8    342.95
Guardado en resumen.json
código de salida: 0

Crear la solución

Una solución agrupa varios proyectos que se compilan juntos. Se crea con cuatro órdenes: la solución, la aplicación de consola, el proyecto de pruebas y la orden que mete los dos proyectos en la solución:

dotnet new sln -n Gastos
dotnet new console -o src/Gastos
dotnet new xunit -o tests/Gastos.Tests
dotnet sln add src/Gastos tests/Gastos.Tests
salidaLa plantilla "Archivo de la solución" se creó correctamente.

La plantilla "Aplicación de consola" se creó correctamente.

Procesando acciones posteriores a la creación...
Restaurando src/Gastos/Gastos.csproj:
  Determinando los proyectos que se van a restaurar...
  Se ha restaurado src/Gastos/Gastos.csproj (en 55 ms).
Restauración realizada correctamente.

La plantilla "Proyecto de prueba de xUnit" se creó correctamente.

Procesando acciones posteriores a la creación...
Restaurando tests/Gastos.Tests/Gastos.Tests.csproj:
  Determinando los proyectos que se van a restaurar...
  Se ha restaurado tests/Gastos.Tests/Gastos.Tests.csproj (en 3.66 s).
Restauración realizada correctamente.

Se ha agregado el proyecto "src/Gastos/Gastos.csproj" a la solución.
Se ha agregado el proyecto "tests/Gastos.Tests/Gastos.Tests.csproj" a la solución.

Desde .NET 10, la solución se guarda en el formato nuevo .slnx, un XML corto y legible, en lugar del antiguo .sln. Después de copiar nuestro código y borrar el UnitTest1.cs de ejemplo, el proyecto queda así:

archivosGastos.slnx
gastos.csv
src/Gastos/Gasto.cs
src/Gastos/Gastos.csproj
src/Gastos/Program.cs
src/Gastos/Resumen.cs
tests/Gastos.Tests/Gastos.Tests.csproj
tests/Gastos.Tests/ResumenTests.cs

La solución Gastos.slnx contiene dos proyectos: src/Gastos, la aplicación de consola con Program.cs, Gasto.cs y Resumen.cs, y tests/Gastos.Tests, con ResumenTests.cs, que tiene una referencia al primero para poder usar sus clasesLa solución Gastos: dos proyectos y una referenciaGastos.slnxsrc/Gastosaplicación de consolaProgram.csGasto.csResumen.cstests/Gastos.Testspruebas con xUnitResumenTests.csreferencia: usa Gastoy Resumen del otro proyecto

La aplicación y sus pruebas son dos proyectos distintos dentro de la misma solución. Las pruebas necesitan una referencia a la aplicación para usar sus clases. Esquema: decodigo.com.

Separar las pruebas en su propio proyecto es la costumbre en .NET: así no viajan dentro del programa que entregas. El .csproj de la aplicación es el mismo que creó dotnet new console en la lección 1.

Las clases

Cada tipo va en su propio archivo con el nombre del tipo, y todos comparten el espacio de nombres Gastos. La línea namespace Gastos; al principio del archivo lo declara para todo él. El primero es el record que representa una línea del CSV:

using System.Globalization;

namespace Gastos;

public record Gasto(DateOnly Fecha, string Categoria, decimal Importe)
{
    // Una línea del CSV: fecha,categoria,importe
    public static Gasto Parse(string linea)
    {
        var partes = linea.Split(',');
        if (partes.Length != 3)
        {
            throw new FormatException($"Se esperaban 3 columnas: {linea}");
        }

        return new Gasto(
            DateOnly.ParseExact(partes[0], "yyyy-MM-dd", CultureInfo.InvariantCulture),
            partes[1].Trim(),
            decimal.Parse(partes[2], CultureInfo.InvariantCulture));
    }
}

Parse es un método static del propio record, como int.Parse: convierte el texto o lanza una excepción si no puede. DateOnly guarda solo una fecha, sin hora. Y CultureInfo.InvariantCulture es importante: sin él, en un PC configurado en español, decimal.Parse("54.20") leería el punto como separador de miles y daría 5420. Es la trampa de la coma que vimos en la lección 2.

El segundo archivo tiene el cálculo, con LINQ, en una clase static que no guarda nada: solo agrupa funciones.

namespace Gastos;

public record TotalCategoria(string Categoria, int Movimientos, decimal Total);

public static class Resumen
{
    public static List<TotalCategoria> PorCategoria(IEnumerable<Gasto> gastos) =>
        gastos
            .GroupBy(g => g.Categoria)
            .Select(g => new TotalCategoria(g.Key, g.Count(), g.Sum(x => x.Importe)))
            .OrderByDescending(t => t.Total)
            .ToList();

    public static decimal TotalDelMes(IEnumerable<Gasto> gastos, int anio, int mes) =>
        gastos
            .Where(g => g.Fecha.Year == anio && g.Fecha.Month == mes)
            .Sum(g => g.Importe);
}

Fíjate en que Resumen no lee archivos ni escribe en la consola: recibe gastos y devuelve resultados. Eso es lo que permite probarlo sin preparar archivos, como veremos enseguida. Lo de entrar y salir del programa queda en Program.cs:

using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Unicode;
using Gastos;

if (args.Length != 1)
{
    Console.Error.WriteLine("Uso: Gastos <archivo.csv>");
    return 1;
}

var gastos = File.ReadLines(args[0])
    .Skip(1)                                   // la cabecera
    .Where(linea => !string.IsNullOrWhiteSpace(linea))
    .Select(Gasto.Parse)
    .ToList();

var resumen = Resumen.PorCategoria(gastos);
foreach (var t in resumen)
{
    Console.WriteLine($"{t.Categoria,-14}{t.Movimientos,3}{t.Total,10:F2}");
}
Console.WriteLine($"{"Total",-14}{gastos.Count,3}{gastos.Sum(g => g.Importe),10:F2}");

var opciones = new JsonSerializerOptions
{
    WriteIndented = true,
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    Encoder = JavaScriptEncoder.Create(UnicodeRanges.All),   // deja las tildes como están
};
File.WriteAllText("resumen.json", JsonSerializer.Serialize(resumen, opciones));
Console.WriteLine("Guardado en resumen.json");
return 0;

Cada línea de gastos.csv pasa por Gasto.Parse, que la convierte en un record Gasto; Resumen.PorCategoria agrupa los gastos con LINQ y produce un TotalCategoria por categoría, que se escribe en la consola y en resumen.jsonQué recorre un dato, de la línea del CSV al JSONgastos.csvtexto, línea a líneaGasto.Parsetexto a recordPorCategoriaLINQ: agrupa y sumaresumen.jsony la tabla en consolaCSV: 2026-10-05,Ocio,40.00Gasto: 2026-10-05 | Ocio | 40.00TotalCategoria: Ocio | 2 movimientos | 58.90

Cada línea del CSV se convierte en un Gasto, y los gastos se agrupan en un TotalCategoria por categoría. Esquema: decodigo.com.

Algunos detalles de Program.cs. File.ReadLines lee el archivo línea a línea sin cargarlo entero; Skip(1) salta la cabecera. .Select(Gasto.Parse) pasa el método directamente, sin escribir la lambda linea => Gasto.Parse(linea). Y las instrucciones de nivel superior pueden devolver un número con return: es el código de salida del programa, 0 si todo fue bien, que leen los scripts y las herramientas de integración continua.

Ejecutar el proyecto

Con varios proyectos, dotnet run necesita saber cuál ejecutar (--project), y los argumentos del programa van detrás de --:

dotnet run --project src/Gastos -- gastos.csv
salidaAlimentación    3    139.05
Hogar           1    120.00
Ocio            2     58.90
Transporte      2     25.00
Total           8    342.95
Guardado en resumen.json
código de salida: 0
resumen.json[
  {
    "categoria": "Alimentación",
    "movimientos": 3,
    "total": 139.05
  },
  {
    "categoria": "Hogar",
    "movimientos": 1,
    "total": 120.00
  },
  {
    "categoria": "Ocio",
    "movimientos": 2,
    "total": 58.90
  },
  {
    "categoria": "Transporte",
    "movimientos": 2,
    "total": 25.00
  }
]

Las propiedades salen en camelCase por la opción PropertyNamingPolicy, que es lo habitual en JSON. Sin argumentos, el programa escribe el uso en la salida de error y termina con código 1:

salida con errorUso: Gastos <archivo.csv>
código de salida: 1

JSON y las tildes

¿Para qué es la opción Encoder de Program.cs? Por omisión, System.Text.Json escapa todo lo que no es ASCII, por seguridad si el JSON acaba dentro de una página web. Funciona, pero el archivo se vuelve difícil de leer:

#:property PublishAot=false
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Unicode;

var dato = new { Categoria = "Alimentación", Total = 139.05m };
Console.WriteLine(JsonSerializer.Serialize(dato));

var opciones = new JsonSerializerOptions { Encoder = JavaScriptEncoder.Create(UnicodeRanges.All) };
Console.WriteLine(JsonSerializer.Serialize(dato, opciones));
salida{"Categoria":"Alimentaci\u00F3n","Total":139.05}
{"Categoria":"Alimentación","Total":139.05}
¿Y esa primera línea? Este fragmento es un archivo suelto, y en .NET 10 los archivos sueltos se compilan por omisión preparados para AOT (código nativo sin JIT), un modo en el que JsonSerializer no puede usar reflexión para descubrir las propiedades: el programa compila con avisos y falla al ejecutarse. #:property PublishAot=false lo desactiva para este archivo. En un proyecto con .csproj, como Gastos, AOT está desactivado salvo que lo pidas, y no hace falta. Hay más sobre JSON en el ejemplo leer y escribir JSON en C#.

Pruebas con xUnit

Las pruebas automáticas comprueban que el código hace lo que debe, y que lo sigue haciendo cuando lo cambias. xUnit es la biblioteca de pruebas más usada en .NET, y es la que trae la plantilla dotnet new xunit. Cada prueba es un método marcado con [Fact]; con [Theory] e [InlineData], el mismo método se ejecuta con varios juegos de datos:

using Gastos;

namespace Gastos.Tests;

public class ResumenTests
{
    private static readonly List<Gasto> Ejemplo =
    [
        new(new DateOnly(2026, 9, 30), "Ocio", 10m),
        new(new DateOnly(2026, 10, 1), "Hogar", 50m),
        new(new DateOnly(2026, 10, 3), "Ocio", 15m),
    ];

    [Fact]
    public void Parse_lee_una_linea_del_csv()
    {
        var gasto = Gasto.Parse("2026-10-05,Ocio,40.00");

        Assert.Equal(new DateOnly(2026, 10, 5), gasto.Fecha);
        Assert.Equal("Ocio", gasto.Categoria);
        Assert.Equal(40.00m, gasto.Importe);
    }

    [Fact]
    public void Parse_rechaza_una_linea_incompleta()
    {
        Assert.Throws<FormatException>(() => Gasto.Parse("2026-10-05,Ocio"));
    }

    [Fact]
    public void PorCategoria_suma_y_ordena_de_mayor_a_menor()
    {
        var resumen = Resumen.PorCategoria(Ejemplo);

        Assert.Equal(2, resumen.Count);
        Assert.Equal(new TotalCategoria("Hogar", 1, 50m), resumen[0]);
        Assert.Equal(new TotalCategoria("Ocio", 2, 25m), resumen[1]);
    }

    [Theory]
    [InlineData(2026, 9, 10)]
    [InlineData(2026, 10, 65)]
    [InlineData(2026, 11, 0)]
    public void TotalDelMes_solo_cuenta_ese_mes(int anio, int mes, int esperado)
    {
        Assert.Equal(esperado, Resumen.TotalDelMes(Ejemplo, anio, mes));
    }
}

Cada prueba sigue el mismo esquema: prepara los datos, llama al código y comprueba el resultado con Assert. La tercera compara records enteros con Assert.Equal: funciona porque los records se comparan por valor. Y las pruebas usan una lista escrita a mano, no el CSV: por eso convenía que Resumen no leyera archivos.

El error que te ahorra: olvidar la referencia entre proyectos

dotnet build tests/Gastos.Tests
dotnet buildtests/Gastos.Tests/ResumenTests.cs(7,34): error CS0246: El nombre del tipo o del espacio de nombres 'Gasto' no se encontró (¿falta una directiva using o una referencia de ensamblado?)

El proyecto de pruebas no sabe nada de Gastos hasta que se le añade una referencia. El compilador no te deja ni empezar a ejecutar las pruebas: en un lenguaje dinámico, el fallo saldría al cargar el módulo, a mitad de la ejecución. La solución es una orden:

dotnet add tests/Gastos.Tests reference src/Gastos

La orden añade al .csproj de las pruebas un <ProjectReference>, junto a los paquetes de xUnit que puso la plantilla:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="coverlet.collector" Version="6.0.4" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
    <PackageReference Include="xunit" Version="2.9.3" />
    <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
  </ItemGroup>

  <ItemGroup>
    <Using Include="Xunit" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\..\src\Gastos\Gastos.csproj" />
  </ItemGroup>

</Project>

Y ya se pueden ejecutar todas las pruebas de la solución:

dotnet test
dotnet testCorrectas! - Con error:     0, Superado:     6, Omitido:     0, Total:     6, Duración: 21 ms - Gastos.Tests.dll (net10.0)

Para ver qué pasa cuando una prueba falla, cambiamos a propósito OrderByDescending por OrderBy en Resumen.cs, como si nos hubiéramos equivocado al escribirlo:

dotnet test  Con error Gastos.Tests.ResumenTests.PorCategoria_suma_y_ordena_de_mayor_a_menor
  Mensaje de error:
   Assert.Equal() Failure: Values differ
Expected: TotalCategoria { Categoria = Hogar, Movimientos = 1, Total = 50 }
Actual:   TotalCategoria { Categoria = Ocio, Movimientos = 2, Total = 25 }
     at Gastos.Tests.ResumenTests.PorCategoria_suma_y_ordena_de_mayor_a_menor() in tests/Gastos.Tests/ResumenTests.cs:line 36
Con error! - Con error:     1, Superado:     5, Omitido:     0, Total:     6, Duración: 29 ms - Gastos.Tests.dll (net10.0)

La prueba dice qué esperaba (Expected), qué recibió (Actual) y en qué línea; los mensajes de xUnit salen en inglés aunque los de dotnet test estén en español. Las otras cinco pruebas siguen pasando: solo falla la que comprueba el orden. Ese es el valor de las pruebas: el error se habría colado sin que el programa fallara, solo con el orden de la tabla al revés. Con dotnet test en cada cambio, lo ves al momento.

Publicar

Para entregar el programa no hace falta que la otra persona tenga el código ni el SDK. dotnet publish compila en modo Release, optimizado, y deja en una carpeta todo lo necesario para ejecutarlo:

dotnet publish src/Gastos -c Release -o publicado
salida  Determinando los proyectos que se van a restaurar...
  Todos los proyectos están actualizados para la restauración.
  Gastos -> src/Gastos/bin/Release/net10.0/Gastos.dll
  Gastos -> publicado/
publicadoGastos
Gastos.deps.json
Gastos.dll
Gastos.pdb
Gastos.runtimeconfig.json
./publicado/Gastos gastos.csvAlimentación    3    139.05
Hogar           1    120.00
Ocio            2     58.90
Transporte      2     25.00
Total           8    342.95
Guardado en resumen.json

Esa carpeta necesita que el equipo de destino tenga instalado el runtime de .NET 10 (no el SDK, que es mucho más grande). Si no quieres ni eso, hay dos opciones: --self-contained mete el runtime dentro de la carpeta, y -p:PublishAot=true compila a código nativo de verdad, un solo ejecutable que arranca al instante, a cambio de algunas limitaciones como la reflexión que vimos con JSON. En los dos casos se indica para qué sistema, por ejemplo -r linux-x64 o -r win-x64.

Resumen

ParaEscribeRecuerda
Crear una solucióndotnet new sln -n Gastosen .NET 10, formato .slnx
Añadir proyectosdotnet sln add src/Gastos
Que un proyecto use otrodotnet add <a> reference <b>sin ella, CS0246
Ejecutar uno concretodotnet run --project src/Gastos -- argslos argumentos, tras --
Leer números de un archivodecimal.Parse(t, CultureInfo.InvariantCulture)si no, la coma te engaña
Guardar JSONJsonSerializer.Serialize(obj, opciones)Encoder para las tildes
Una prueba[Fact], o [Theory] con [InlineData]Assert.Equal(esperado, real)
Ejecutar las pruebasdotnet testen cada cambio
Entregar el programadotnet publish -c Release -o carpetao --self-contained, o AOT

Con esto termina el curso. Ya sabes lo necesario para leer y escribir C# moderno: el siguiente paso es practicar. Prueba el test de 24 preguntas, repasa las rarezas y mira los ejemplos de C# para tareas concretas, como leer un CSV, argumentos de la línea de órdenes o guardar datos en SQLite, que encajarían bien como siguiente versión de Gastos.

Retos

Los retos son programas de un solo archivo, con una versión reducida de Gasto, para probarlos sin montar la solución. Llevarlos al proyecto, con su prueba, es parte del reto.

Reto 1. Añade a Resumen un método que devuelva el gasto más alto. Pista: LINQ tiene MaxBy, que devuelve el elemento, no el valor.

Ver la solución
Console.WriteLine(Resumen.MayorGasto(gastos));

static class Resumen
{
    public static Gasto? MayorGasto(IEnumerable<Gasto> gastos) => gastos.MaxBy(g => g.Importe);
}
salidaGasto { Fecha = 10/15/2026, Categoria = Hogar, Importe = 120.00 }

Devuelve Gasto? porque, con una lista vacía, MaxBy da null. Y fíjate en cómo se imprime un record: con su nombre y todas sus propiedades, gracias al ToString que el compilador le escribe. La fecha sale como mes/día/año porque este curso se ejecutó sin idioma configurado; en un PC en español saldría 15/10/2026.

Reto 2. Ahora mismo, una sola línea mal escrita en el CSV tumba el programa. Escribe un Gasto.IntentarLeer que devuelva null en vez de lanzar, y cuenta cuántas líneas se ignoraron.

Ver la solución
using System.Globalization;

string[] lineas = ["2026-10-05,Ocio,40.00", "2026-10-06,Ocio", "ayer,Hogar,10", "2026-10-07,Hogar,12.5"];

var gastos = lineas.Select(Gasto.IntentarLeer).ToList();
Console.WriteLine($"Leídos: {gastos.Count(g => g is not null)}, ignorados: {gastos.Count(g => g is null)}");

record Gasto(DateOnly Fecha, string Categoria, decimal Importe)
{
    public static Gasto? IntentarLeer(string linea)
    {
        var partes = linea.Split(',');
        if (partes.Length == 3
            && DateOnly.TryParseExact(partes[0], "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var fecha)
            && decimal.TryParse(partes[2], CultureInfo.InvariantCulture, out var importe))
        {
            return new Gasto(fecha, partes[1].Trim(), importe);
        }
        return null;
    }
}
salidaLeídos: 2, ignorados: 2

Es el patrón TryParse de la lección 2 aplicado a nuestro tipo. En el proyecto real convendría además decir qué líneas se ignoraron, escribiéndolas en Console.Error.

Reto 3. Escribe el total de cada mes, en orden cronológico, con el formato 2026-10: 183.75.

Ver la solución
var porMes = gastos
    .GroupBy(g => new DateOnly(g.Fecha.Year, g.Fecha.Month, 1))
    .OrderBy(g => g.Key)
    .Select(g => $"{g.Key:yyyy-MM}: {g.Sum(x => x.Importe)}");

foreach (var linea in porMes)
{
    Console.WriteLine(linea);
}
salida2026-09: 54.20
2026-10: 183.75

Agrupar por el primer día del mes es un truco cómodo: la clave es una fecha, que ya se ordena bien y se formatea con yyyy-MM. También valdría una tupla (g.Fecha.Year, g.Fecha.Month).

Todo el código de esta lección se compiló con el .NET SDK 10.0.403 (C# 14) y se ejecutó con .NET 10.0.14 en un contenedor limpio antes de publicarla; los mensajes del compilador y las salidas están copiados de esa ejecución. ¿Algo no te funciona? Revisa que tengas .NET 10: dotnet --version.